From 582b923b8f3f7104411c39ebdba63949d606ecd1 Mon Sep 17 00:00:00 2001 From: David Garcia Date: Tue, 26 Oct 2021 12:30:44 +0200 Subject: [PATCH] Feature 10887: Add cross-model relations support Changes: - Extend `add_relation` method in N2VCJujuConn to include the CMR case - Add `add_relation` method to K8sJujuConn - Add n2vc/definitions.py file that includes definition ofjects for Offer and RelationEndpoint. - Change `n2vc.libjuju.Libjuju.list_offers` method to be private, and accept a filter `offer_name` parameter. - Update `n2vc.libjuju.Libjuju.consume` method arguments. - Add `n2vc.libjuju.Libjuju.offer` method to create an offer. Unit tests associated to the code changes have been either created or updated accordingly Change-Id: Ibf8d574528dee0fa898e0e97578dd3a6aa68316a Signed-off-by: David Garcia --- n2vc/definitions.py | 108 +++++++++++++++++ n2vc/k8s_juju_conn.py | 48 ++++++++ n2vc/libjuju.py | 82 ++++++++++--- n2vc/n2vc_juju_conn.py | 103 ++++++----------- n2vc/tests/unit/test_definitions.py | 48 ++++++++ n2vc/tests/unit/test_k8s_juju_conn.py | 48 ++++++++ n2vc/tests/unit/test_libjuju.py | 154 ++++++++++++++++++++++--- n2vc/tests/unit/test_n2vc_juju_conn.py | 46 ++++++++ n2vc/tests/unit/test_utils.py | 22 +++- n2vc/utils.py | 16 +++ 10 files changed, 580 insertions(+), 95 deletions(-) create mode 100644 n2vc/definitions.py create mode 100644 n2vc/tests/unit/test_definitions.py diff --git a/n2vc/definitions.py b/n2vc/definitions.py new file mode 100644 index 0000000..92d4f51 --- /dev/null +++ b/n2vc/definitions.py @@ -0,0 +1,108 @@ +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import NoReturn + +from n2vc.utils import get_ee_id_components + + +class RelationEndpoint: + """Represents an endpoint of an application""" + + def __init__(self, ee_id: str, vca_id: str, endpoint_name: str) -> NoReturn: + """ + Args: + ee_id: Execution environment id. + Format: "..". + vca_id: Id of the VCA. Identifies the Juju Controller + where the application is deployed + endpoint_name: Name of the endpoint for the relation + """ + ee_components = get_ee_id_components(ee_id) + self._model_name = ee_components[0] + self._application_name = ee_components[1] + self._vca_id = vca_id + self._endpoint_name = endpoint_name + + @property + def application_name(self) -> str: + """Returns the application name""" + return self._application_name + + @property + def endpoint(self) -> str: + """Returns the application name and the endpoint. Format: :""" + return f"{self.application_name}:{self._endpoint_name}" + + @property + def endpoint_name(self) -> str: + """Returns the endpoint name""" + return self._endpoint_name + + @property + def model_name(self) -> str: + """Returns the model name""" + return self._model_name + + @property + def vca_id(self) -> str: + """Returns the vca id""" + return self._vca_id + + def __str__(self) -> str: + app = self.application_name + endpoint = self.endpoint_name + model = self.model_name + vca = self.vca_id + return f"{app}:{endpoint} (model: {model}, vca: {vca})" + + +class Offer: + """Represents a juju offer""" + + def __init__(self, url: str, vca_id: str = None) -> NoReturn: + """ + Args: + url: Offer url. Format: /.. + """ + self._url = url + self._username = url.split(".")[0].split("/")[0] + self._model_name = url.split(".")[0].split("/")[1] + self._name = url.split(".")[1] + self._vca_id = vca_id + + @property + def model_name(self) -> str: + """Returns the model name""" + return self._model_name + + @property + def name(self) -> str: + """Returns the offer name""" + return self._name + + @property + def username(self) -> str: + """Returns the username""" + return self._username + + @property + def url(self) -> str: + """Returns the offer url""" + return self._url + + @property + def vca_id(self) -> str: + """Returns the vca id""" + return self._vca_id diff --git a/n2vc/k8s_juju_conn.py b/n2vc/k8s_juju_conn.py index 149947d..f8ed0e0 100644 --- a/n2vc/k8s_juju_conn.py +++ b/n2vc/k8s_juju_conn.py @@ -20,6 +20,7 @@ import tempfile import binascii from n2vc.config import EnvironConfig +from n2vc.definitions import RelationEndpoint from n2vc.exceptions import K8sException from n2vc.k8s_conn import K8sConnector from n2vc.kubectl import Kubectl @@ -673,6 +674,53 @@ class K8sJujuConnector(K8sConnector): return status + async def add_relation( + self, + provider: RelationEndpoint, + requirer: RelationEndpoint, + ): + """ + Add relation between two charmed endpoints + + :param: provider: Provider relation endpoint + :param: requirer: Requirer relation endpoint + """ + self.log.debug(f"adding new relation between {provider} and {requirer}") + cross_model_relation = ( + provider.model_name != requirer.model_name + or requirer.vca_id != requirer.vca_id + ) + try: + if cross_model_relation: + # Cross-model relation + provider_libjuju = await self._get_libjuju(provider.vca_id) + requirer_libjuju = await self._get_libjuju(requirer.vca_id) + offer = await provider_libjuju.offer(provider) + if offer: + saas_name = await requirer_libjuju.consume( + requirer.model_name, offer, provider_libjuju + ) + await requirer_libjuju.add_relation( + requirer.model_name, + requirer.endpoint, + saas_name, + ) + else: + # Standard relation + vca_id = provider.vca_id + model = provider.model_name + libjuju = await self._get_libjuju(vca_id) + # add juju relations between two applications + await libjuju.add_relation( + model_name=model, + endpoint_1=provider.endpoint, + endpoint_2=requirer.endpoint, + ) + except Exception as e: + message = f"Error adding relation between {provider} and {requirer}: {e}" + self.log.error(message) + raise Exception(message=message) + async def update_vca_status(self, vcastatus: dict, kdu_instance: str, **kwargs): """ Add all configs, actions, executed actions of all applications in a model to vcastatus dict diff --git a/n2vc/libjuju.py b/n2vc/libjuju.py index 6580067..a903860 100644 --- a/n2vc/libjuju.py +++ b/n2vc/libjuju.py @@ -33,6 +33,7 @@ from juju.controller import Controller from juju.client import client from juju import tag +from n2vc.definitions import Offer, RelationEndpoint from n2vc.juju_watcher import JujuModelWatcher from n2vc.provisioner import AsyncSSHProvisioner from n2vc.n2vc_conn import N2VCConnector @@ -1128,28 +1129,71 @@ class Libjuju: await self.disconnect_model(model) await self.disconnect_controller(controller) + async def offer(self, endpoint: RelationEndpoint) -> Offer: + """ + Create an offer from a RelationEndpoint + + :param: endpoint: Relation endpoint + + :return: Offer object + """ + model_name = endpoint.model_name + offer_name = f"{endpoint.application_name}-{endpoint.endpoint_name}" + controller = await self.get_controller() + model = None + try: + model = await self.get_model(controller, model_name) + await model.create_offer(endpoint.endpoint, offer_name=offer_name) + offer_list = await self._list_offers(model_name, offer_name=offer_name) + if offer_list: + return Offer(offer_list[0].offer_url) + else: + raise Exception("offer was not created") + except juju.errors.JujuError as e: + if "application offer already exists" not in e.message: + raise e + finally: + if model: + self.disconnect_model(model) + self.disconnect_controller(controller) + async def consume( self, - offer_url: str, model_name: str, - ): + offer: Offer, + provider_libjuju: "Libjuju", + ) -> str: """ - Adds a remote offer to the model. Relations can be created later using "juju relate". + Consumes a remote offer in the model. Relations can be created later using "juju relate". - :param: offer_url: Offer Url - :param: model_name: Model name + :param: model_name: Model name + :param: offer: Offer object to consume + :param: provider_libjuju: Libjuju object of the provider endpoint :raises ParseError if there's a problem parsing the offer_url :raises JujuError if remote offer includes and endpoint :raises JujuAPIError if the operation is not successful + + :returns: Saas name. It is the application name in the model that reference the remote application. """ + saas_name = f'{offer.name}-{offer.model_name.replace("-", "")}' + if offer.vca_id: + saas_name = f"{saas_name}-{offer.vca_id}" controller = await self.get_controller() - model = await controller.get_model(model_name) - + model = None + provider_controller = None try: - await model.consume(offer_url) + model = await controller.get_model(model_name) + provider_controller = await provider_libjuju.get_controller() + await model.consume( + offer.url, application_alias=saas_name, controller=provider_controller + ) + return saas_name finally: - await self.disconnect_model(model) + if model: + await self.disconnect_model(model) + if provider_controller: + await provider_libjuju.disconnect_controller(provider_controller) await self.disconnect_controller(controller) async def destroy_model(self, model_name: str, total_timeout: float = 1800): @@ -1346,17 +1390,29 @@ class Libjuju: finally: await self.disconnect_controller(controller) - async def list_offers(self, model_name: str) -> QueryApplicationOffersResults: - """List models with certain names + async def _list_offers( + self, model_name: str, offer_name: str = None + ) -> QueryApplicationOffersResults: + """ + List offers within a model :param: model_name: Model name + :param: offer_name: Offer name to filter. - :return: Returns list of offers + :return: Returns application offers results in the model """ controller = await self.get_controller() try: - return await controller.list_offers(model_name) + offers = (await controller.list_offers(model_name)).results + if offer_name: + matching_offer = [] + for offer in offers: + if offer.offer_name == offer_name: + matching_offer.append(offer) + break + offers = matching_offer + return offers finally: await self.disconnect_controller(controller) diff --git a/n2vc/n2vc_juju_conn.py b/n2vc/n2vc_juju_conn.py index c01c436..55220d6 100644 --- a/n2vc/n2vc_juju_conn.py +++ b/n2vc/n2vc_juju_conn.py @@ -24,6 +24,7 @@ import asyncio import logging from n2vc.config import EnvironConfig +from n2vc.definitions import RelationEndpoint from n2vc.exceptions import ( N2VCBadArgumentsException, N2VCException, @@ -38,6 +39,7 @@ from n2vc.n2vc_conn import N2VCConnector from n2vc.n2vc_conn import obj_to_dict, obj_to_yaml from n2vc.libjuju import Libjuju from n2vc.store import MotorStore +from n2vc.utils import get_ee_id_components from n2vc.vca.connection import get_connection from retrying_async import retry @@ -716,69 +718,48 @@ class N2VCJujuConnector(N2VCConnector): async def add_relation( self, - ee_id_1: str, - ee_id_2: str, - endpoint_1: str, - endpoint_2: str, - vca_id: str = None, + provider: RelationEndpoint, + requirer: RelationEndpoint, ): """ Add relation between two charmed endpoints - :param: ee_id_1: The id of the first execution environment - :param: ee_id_2: The id of the second execution environment - :param: endpoint_1: The endpoint in the first execution environment - :param: endpoint_2: The endpoint in the second execution environment - :param: vca_id: VCA ID + :param: provider: Provider relation endpoint + :param: requirer: Requirer relation endpoint """ - self.log.debug( - "adding new relation between {} and {}, endpoints: {}, {}".format( - ee_id_1, ee_id_2, endpoint_1, endpoint_2 - ) + self.log.debug(f"adding new relation between {provider} and {requirer}") + cross_model_relation = ( + provider.model_name != requirer.model_name + or requirer.vca_id != requirer.vca_id ) - libjuju = await self._get_libjuju(vca_id) - - # check arguments - if not ee_id_1: - message = "EE 1 is mandatory" - self.log.error(message) - raise N2VCBadArgumentsException(message=message, bad_args=["ee_id_1"]) - if not ee_id_2: - message = "EE 2 is mandatory" - self.log.error(message) - raise N2VCBadArgumentsException(message=message, bad_args=["ee_id_2"]) - if not endpoint_1: - message = "endpoint 1 is mandatory" - self.log.error(message) - raise N2VCBadArgumentsException(message=message, bad_args=["endpoint_1"]) - if not endpoint_2: - message = "endpoint 2 is mandatory" - self.log.error(message) - raise N2VCBadArgumentsException(message=message, bad_args=["endpoint_2"]) - - # get the model, the applications and the machines from the ee_id's - model_1, app_1, _machine_1 = self._get_ee_id_components(ee_id_1) - model_2, app_2, _machine_2 = self._get_ee_id_components(ee_id_2) - - # model must be the same - if model_1 != model_2: - message = "EE models are not the same: {} vs {}".format(ee_id_1, ee_id_2) - self.log.error(message) - raise N2VCBadArgumentsException( - message=message, bad_args=["ee_id_1", "ee_id_2"] - ) - - # add juju relations between two applications try: - await libjuju.add_relation( - model_name=model_1, - endpoint_1="{}:{}".format(app_1, endpoint_1), - endpoint_2="{}:{}".format(app_2, endpoint_2), - ) + if cross_model_relation: + # Cross-model relation + provider_libjuju = await self._get_libjuju(provider.vca_id) + requirer_libjuju = await self._get_libjuju(requirer.vca_id) + offer = await provider_libjuju.offer(provider) + if offer: + saas_name = await requirer_libjuju.consume( + requirer.model_name, offer, provider_libjuju + ) + await requirer_libjuju.add_relation( + requirer.model_name, + requirer.endpoint, + saas_name, + ) + else: + # Standard relation + vca_id = provider.vca_id + model = provider.model_name + libjuju = await self._get_libjuju(vca_id) + # add juju relations between two applications + await libjuju.add_relation( + model_name=model, + endpoint_1=provider.endpoint, + endpoint_2=requirer.endpoint, + ) except Exception as e: - message = "Error adding relation between {} and {}: {}".format( - ee_id_1, ee_id_2, e - ) + message = f"Error adding relation between {provider} and {requirer}: {e}" self.log.error(message) raise N2VCException(message=message) @@ -1047,7 +1028,7 @@ class N2VCJujuConnector(N2VCConnector): machine_id=machine_id, progress_timeout=progress_timeout, total_timeout=total_timeout, - **params_dict + **params_dict, ) if status == "completed": return output @@ -1153,15 +1134,7 @@ class N2VCJujuConnector(N2VCConnector): :return: model_name, application_name, machine_id """ - if ee_id is None: - return None, None, None - - # split components of id - parts = ee_id.split(".") - model_name = parts[0] - application_name = parts[1] - machine_id = parts[2] - return model_name, application_name, machine_id + return get_ee_id_components(ee_id) def _get_application_name(self, namespace: str) -> str: """ diff --git a/n2vc/tests/unit/test_definitions.py b/n2vc/tests/unit/test_definitions.py new file mode 100644 index 0000000..5d58a76 --- /dev/null +++ b/n2vc/tests/unit/test_definitions.py @@ -0,0 +1,48 @@ +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import NoReturn +from unittest import TestCase +from unittest.mock import patch + +from n2vc.definitions import Offer, RelationEndpoint + + +@patch("n2vc.definitions.get_ee_id_components") +class RelationEndpointTest(TestCase): + def test_success(self, mock_get_ee_id_components) -> NoReturn: + mock_get_ee_id_components.return_value = ("model", "application", "machine_id") + relation_endpoint = RelationEndpoint( + "model.application.machine_id", + "vca", + "endpoint", + ) + self.assertEqual(relation_endpoint.model_name, "model") + self.assertEqual(relation_endpoint.application_name, "application") + self.assertEqual(relation_endpoint.vca_id, "vca") + self.assertEqual(relation_endpoint.endpoint, "application:endpoint") + self.assertEqual(relation_endpoint.endpoint_name, "endpoint") + self.assertEqual( + str(relation_endpoint), "application:endpoint (model: model, vca: vca)" + ) + + +class OfferTest(TestCase): + def test_success(self) -> NoReturn: + url = "admin/test-model.my-offer" + offer = Offer(url) + self.assertEqual(offer.model_name, "test-model") + self.assertEqual(offer.name, "my-offer") + self.assertEqual(offer.username, "admin") + self.assertEqual(offer.url, url) diff --git a/n2vc/tests/unit/test_k8s_juju_conn.py b/n2vc/tests/unit/test_k8s_juju_conn.py index 1423c61..e0faaf0 100644 --- a/n2vc/tests/unit/test_k8s_juju_conn.py +++ b/n2vc/tests/unit/test_k8s_juju_conn.py @@ -17,6 +17,7 @@ import asyncio import logging import asynctest from unittest.mock import Mock +from n2vc.definitions import Offer, RelationEndpoint from n2vc.k8s_juju_conn import K8sJujuConnector, RBAC_LABEL_KEY_NAME from osm_common import fslocal from .utils import kubeconfig, FakeModel, FakeFileWrapper, AsyncMock, FakeApplication @@ -721,3 +722,50 @@ class GetScaleCount(K8sJujuConnTestCase): ) self.assertIsNone(status) self.k8s_juju_conn.libjuju.get_model_status.assert_called_once() + + +class AddRelationTest(K8sJujuConnTestCase): + def setUp(self): + super(AddRelationTest, self).setUp() + self.k8s_juju_conn.libjuju.add_relation = AsyncMock() + self.k8s_juju_conn.libjuju.offer = AsyncMock() + self.k8s_juju_conn.libjuju.get_controller = AsyncMock() + self.k8s_juju_conn.libjuju.consume = AsyncMock() + + def test_standard_relation(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-1.app2.1", None, "endpoint") + self.loop.run_until_complete( + self.k8s_juju_conn.add_relation(relation_endpoint_1, relation_endpoint_2) + ) + self.k8s_juju_conn.libjuju.add_relation.assert_called_once_with( + model_name="model-1", endpoint_1="app1:endpoint", endpoint_2="app2:endpoint" + ) + self.k8s_juju_conn.libjuju.offer.assert_not_called() + self.k8s_juju_conn.libjuju.consume.assert_not_called() + + def test_cmr_relation_same_controller(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-2.app2.1", None, "endpoint") + offer = Offer("admin/model-1.app1") + self.k8s_juju_conn.libjuju.offer.return_value = offer + self.k8s_juju_conn.libjuju.consume.return_value = "saas" + self.loop.run_until_complete( + self.k8s_juju_conn.add_relation(relation_endpoint_1, relation_endpoint_2) + ) + self.k8s_juju_conn.libjuju.offer.assert_called_once_with(relation_endpoint_1) + self.k8s_juju_conn.libjuju.consume.assert_called_once() + self.k8s_juju_conn.libjuju.add_relation.assert_called_once_with( + "model-2", "app2:endpoint", "saas" + ) + + def test_relation_exception(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-2.app2.1", None, "endpoint") + self.k8s_juju_conn.libjuju.offer.side_effect = Exception() + with self.assertRaises(Exception): + self.loop.run_until_complete( + self.k8s_juju_conn.add_relation( + relation_endpoint_1, relation_endpoint_2 + ) + ) diff --git a/n2vc/tests/unit/test_libjuju.py b/n2vc/tests/unit/test_libjuju.py index 918a2fb..67cd19f 100644 --- a/n2vc/tests/unit/test_libjuju.py +++ b/n2vc/tests/unit/test_libjuju.py @@ -20,6 +20,8 @@ import juju import kubernetes from juju.errors import JujuAPIError import logging + +from n2vc.definitions import Offer, RelationEndpoint from .utils import ( FakeApplication, FakeMachine, @@ -1415,7 +1417,7 @@ class ListOffers(LibjujuTestCase): mock_get_controller.return_value = juju.controller.Controller() mock_list_offers.side_effect = Exception() with self.assertRaises(Exception): - self.loop.run_until_complete(self.libjuju.list_offers("model")) + self.loop.run_until_complete(self.libjuju._list_offers("model")) mock_disconnect_controller.assert_called_once() def test_empty_list( @@ -1425,8 +1427,10 @@ class ListOffers(LibjujuTestCase): mock_get_controller, ): mock_get_controller.return_value = juju.controller.Controller() - mock_list_offers.return_value = [] - offers = self.loop.run_until_complete(self.libjuju.list_offers("model")) + offer_results = Mock() + offer_results.results = [] + mock_list_offers.return_value = offer_results + offers = self.loop.run_until_complete(self.libjuju._list_offers("model")) self.assertEqual(offers, []) mock_disconnect_controller.assert_called_once() @@ -1437,12 +1441,110 @@ class ListOffers(LibjujuTestCase): mock_get_controller, ): mock_get_controller.return_value = juju.controller.Controller() - mock_list_offers.return_value = ["offer"] - offers = self.loop.run_until_complete(self.libjuju.list_offers("model")) - self.assertEqual(offers, ["offer"]) + offer = Mock() + offer_results = Mock() + offer_results.results = [offer] + mock_list_offers.return_value = offer_results + offers = self.loop.run_until_complete(self.libjuju._list_offers("model")) + self.assertEqual(offers, [offer]) + mock_disconnect_controller.assert_called_once() + + def test_matching_offer_name( + self, + mock_list_offers, + mock_disconnect_controller, + mock_get_controller, + ): + mock_get_controller.return_value = juju.controller.Controller() + offer_1 = Mock() + offer_1.offer_name = "offer1" + offer_2 = Mock() + offer_2.offer_name = "offer2" + offer_results = Mock() + offer_results.results = [offer_1, offer_2] + mock_list_offers.return_value = offer_results + offers = self.loop.run_until_complete( + self.libjuju._list_offers("model", offer_name="offer2") + ) + self.assertEqual(offers, [offer_2]) + mock_disconnect_controller.assert_called_once() + + def test_not_matching_offer_name( + self, + mock_list_offers, + mock_disconnect_controller, + mock_get_controller, + ): + mock_get_controller.return_value = juju.controller.Controller() + offer_1 = Mock() + offer_1.offer_name = "offer1" + offer_2 = Mock() + offer_2.offer_name = "offer2" + offer_results = Mock() + offer_results.results = [offer_1, offer_2] + mock_list_offers.return_value = offer_results + offers = self.loop.run_until_complete( + self.libjuju._list_offers("model", offer_name="offer3") + ) + self.assertEqual(offers, []) mock_disconnect_controller.assert_called_once() +@asynctest.mock.patch("n2vc.libjuju.Libjuju.get_controller") +@asynctest.mock.patch("juju.controller.Controller.get_model") +@asynctest.mock.patch("n2vc.libjuju.Libjuju.disconnect_model") +@asynctest.mock.patch("n2vc.libjuju.Libjuju.disconnect_controller") +@asynctest.mock.patch("n2vc.libjuju.Libjuju._list_offers") +@asynctest.mock.patch("juju.model.Model.create_offer") +class OfferTest(LibjujuTestCase): + def setUp(self): + super(OfferTest, self).setUp() + + def test_offer( + self, + mock_create_offer, + mock__list_offers, + mock_disconnect_controller, + mock_disconnect_model, + mock_get_model, + mock_get_controller, + ): + controller = juju.controller.Controller() + model = juju.model.Model() + mock_get_controller.return_value = controller + mock_get_model.return_value = model + endpoint = RelationEndpoint("model.app-name.0", "vca", "endpoint") + self.loop.run_until_complete(self.libjuju.offer(endpoint)) + mock_create_offer.assert_called_with( + "app-name:endpoint", offer_name="app-name-endpoint" + ) + mock_disconnect_model.assert_called_once_with(model) + mock_disconnect_controller.assert_called_once_with(controller) + + def test_offer_exception( + self, + mock_create_offer, + mock__list_offers, + mock_disconnect_controller, + mock_disconnect_model, + mock_get_model, + mock_get_controller, + ): + controller = juju.controller.Controller() + model = juju.model.Model() + mock_get_controller.return_value = controller + mock_get_model.return_value = model + mock__list_offers.return_value = [] + endpoint = RelationEndpoint("model.app-name.0", "vca", "endpoint") + with self.assertRaises(Exception): + self.loop.run_until_complete(self.libjuju.offer(endpoint)) + mock_create_offer.assert_called_with( + "app-name:endpoint", offer_name="app-name-endpoint" + ) + mock_disconnect_model.assert_called_once_with(model) + mock_disconnect_controller.assert_called_once_with(controller) + + @asynctest.mock.patch("n2vc.libjuju.Libjuju.get_controller") @asynctest.mock.patch("juju.controller.Controller.get_model") @asynctest.mock.patch("n2vc.libjuju.Libjuju.disconnect_model") @@ -1450,7 +1552,9 @@ class ListOffers(LibjujuTestCase): @asynctest.mock.patch("juju.model.Model.consume") class ConsumeTest(LibjujuTestCase): def setUp(self): + self.offer_url = "admin/model.offer_name" super(ConsumeTest, self).setUp() + self.provider_libjuju = self.libjuju def test_consume( self, @@ -1460,13 +1564,25 @@ class ConsumeTest(LibjujuTestCase): mock_get_model, mock_get_controller, ): - mock_get_controller.return_value = juju.controller.Controller() + self_controller = juju.controller.Controller() + provider_controller = juju.controller.Controller() + mock_get_controller.side_effect = [self_controller, provider_controller] mock_get_model.return_value = juju.model.Model() - self.loop.run_until_complete(self.libjuju.consume("offer_url", "model_name")) - mock_consume.assert_called_once() + self.loop.run_until_complete( + self.libjuju.consume( + "model_name", + Offer(self.offer_url, vca_id="vca-id"), + self.provider_libjuju, + ) + ) + mock_consume.assert_called_once_with( + "admin/model.offer_name", + application_alias="offer_name-model-vca-id", + controller=provider_controller, + ) mock_disconnect_model.assert_called_once() - mock_disconnect_controller.assert_called_once() + self.assertEqual(mock_disconnect_controller.call_count, 2) def test_parsing_error_exception( self, @@ -1482,11 +1598,13 @@ class ConsumeTest(LibjujuTestCase): with self.assertRaises(juju.offerendpoints.ParseError): self.loop.run_until_complete( - self.libjuju.consume("offer_url", "model_name") + self.libjuju.consume( + "model_name", Offer(self.offer_url), self.provider_libjuju + ) ) mock_consume.assert_called_once() mock_disconnect_model.assert_called_once() - mock_disconnect_controller.assert_called_once() + self.assertEqual(mock_disconnect_controller.call_count, 2) def test_juju_error_exception( self, @@ -1502,11 +1620,13 @@ class ConsumeTest(LibjujuTestCase): with self.assertRaises(juju.errors.JujuError): self.loop.run_until_complete( - self.libjuju.consume("offer_url", "model_name") + self.libjuju.consume( + "model_name", Offer(self.offer_url), self.provider_libjuju + ) ) mock_consume.assert_called_once() mock_disconnect_model.assert_called_once() - mock_disconnect_controller.assert_called_once() + self.assertEqual(mock_disconnect_controller.call_count, 2) def test_juju_api_error_exception( self, @@ -1524,11 +1644,13 @@ class ConsumeTest(LibjujuTestCase): with self.assertRaises(juju.errors.JujuAPIError): self.loop.run_until_complete( - self.libjuju.consume("offer_url", "model_name") + self.libjuju.consume( + "model_name", Offer(self.offer_url), self.provider_libjuju + ) ) mock_consume.assert_called_once() mock_disconnect_model.assert_called_once() - mock_disconnect_controller.assert_called_once() + self.assertEqual(mock_disconnect_controller.call_count, 2) @asynctest.mock.patch("n2vc.libjuju.Libjuju.get_k8s_cloud_credential") diff --git a/n2vc/tests/unit/test_n2vc_juju_conn.py b/n2vc/tests/unit/test_n2vc_juju_conn.py index d89de3f..2475d01 100644 --- a/n2vc/tests/unit/test_n2vc_juju_conn.py +++ b/n2vc/tests/unit/test_n2vc_juju_conn.py @@ -19,6 +19,7 @@ from unittest.mock import Mock import asynctest +from n2vc.definitions import Offer, RelationEndpoint from n2vc.n2vc_juju_conn import N2VCJujuConnector from osm_common import fslocal from n2vc.exceptions import ( @@ -239,3 +240,48 @@ class K8sProxyCharmsTest(N2VCJujuConnTestCase): ) ) self.assertIsNone(ee_id) + + +class AddRelationTest(N2VCJujuConnTestCase): + def setUp(self): + super(AddRelationTest, self).setUp() + self.n2vc.libjuju.add_relation = AsyncMock() + self.n2vc.libjuju.offer = AsyncMock() + self.n2vc.libjuju.get_controller = AsyncMock() + self.n2vc.libjuju.consume = AsyncMock() + + def test_standard_relation(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-1.app2.1", None, "endpoint") + self.loop.run_until_complete( + self.n2vc.add_relation(relation_endpoint_1, relation_endpoint_2) + ) + self.n2vc.libjuju.add_relation.assert_called_once_with( + model_name="model-1", endpoint_1="app1:endpoint", endpoint_2="app2:endpoint" + ) + self.n2vc.libjuju.offer.assert_not_called() + self.n2vc.libjuju.consume.assert_not_called() + + def test_cmr_relation_same_controller(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-2.app2.1", None, "endpoint") + offer = Offer("admin/model-1.app1") + self.n2vc.libjuju.offer.return_value = offer + self.n2vc.libjuju.consume.return_value = "saas" + self.loop.run_until_complete( + self.n2vc.add_relation(relation_endpoint_1, relation_endpoint_2) + ) + self.n2vc.libjuju.offer.assert_called_once_with(relation_endpoint_1) + self.n2vc.libjuju.consume.assert_called_once() + self.n2vc.libjuju.add_relation.assert_called_once_with( + "model-2", "app2:endpoint", "saas" + ) + + def test_relation_exception(self): + relation_endpoint_1 = RelationEndpoint("model-1.app1.0", None, "endpoint") + relation_endpoint_2 = RelationEndpoint("model-2.app2.1", None, "endpoint") + self.n2vc.libjuju.offer.side_effect = Exception() + with self.assertRaises(N2VCException): + self.loop.run_until_complete( + self.n2vc.add_relation(relation_endpoint_1, relation_endpoint_2) + ) diff --git a/n2vc/tests/unit/test_utils.py b/n2vc/tests/unit/test_utils.py index bffbc29..3896b2f 100644 --- a/n2vc/tests/unit/test_utils.py +++ b/n2vc/tests/unit/test_utils.py @@ -14,7 +14,13 @@ from unittest import TestCase -from n2vc.utils import Dict, EntityType, JujuStatusToOSM, N2VCDeploymentStatus +from n2vc.utils import ( + Dict, + EntityType, + JujuStatusToOSM, + N2VCDeploymentStatus, + get_ee_id_components, +) from juju.machine import Machine from juju.application import Application from juju.action import Action @@ -84,3 +90,17 @@ class UtilsTest(TestCase): osm_status = status["osm"] self.assertTrue(juju_status in JujuStatusToOSM[entity_type]) self.assertEqual(osm_status, JujuStatusToOSM[entity_type][juju_status]) + + +class GetEEComponentTest(TestCase): + def test_valid(self): + model, application, machine = get_ee_id_components("model.application.machine") + self.assertEqual(model, "model") + self.assertEqual(application, "application") + self.assertEqual(machine, "machine") + + def test_invalid(self): + with self.assertRaises(Exception): + get_ee_id_components("model.application.machine.1") + with self.assertRaises(Exception): + get_ee_id_components("model.application") diff --git a/n2vc/utils.py b/n2vc/utils.py index 0dbd71e..a661e05 100644 --- a/n2vc/utils.py +++ b/n2vc/utils.py @@ -22,6 +22,7 @@ from juju.application import Application from juju.action import Action from juju.unit import Unit from n2vc.exceptions import N2VCInvalidCertificate +from typing import Tuple def base64_to_cacert(b64string): @@ -147,3 +148,18 @@ def obj_to_dict(obj: object) -> dict: yaml_text = obj_to_yaml(obj) # parse to dict return yaml.load(yaml_text, Loader=yaml.Loader) + + +def get_ee_id_components(ee_id: str) -> Tuple[str, str, str]: + """ + Get model, application and machine components from an execution environment id + :param ee_id: + :return: model_name, application_name, machine_id + """ + parts = ee_id.split(".") + if len(parts) != 3: + raise Exception("invalid ee id.") + model_name = parts[0] + application_name = parts[1] + machine_id = parts[2] + return model_name, application_name, machine_id -- 2.17.1