Update helm repo after adding the repo
[osm/N2VC.git] / n2vc / libjuju.py
index cb2e0ba..bca3665 100644 (file)
@@ -18,7 +18,7 @@ import typing
 
 import time
 
-from juju.errors import JujuAPIError
+import juju.errors
 from juju.model import Model
 from juju.machine import Machine
 from juju.application import Application
@@ -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
@@ -94,7 +95,7 @@ class Libjuju:
         """
         controller = None
         try:
-            controller = Controller(loop=self.loop)
+            controller = Controller()
             await asyncio.wait_for(
                 controller.connect(
                     endpoint=self.vca_connection.data.endpoints,
@@ -174,7 +175,7 @@ class Libjuju:
                     cloud_name=cloud.name,
                     credential_name=cloud.credential_name,
                 )
-        except JujuAPIError as e:
+        except juju.errors.JujuAPIError as e:
             if "already exists" in e.message:
                 pass
             else:
@@ -273,7 +274,9 @@ class Libjuju:
         """
         return await controller.get_model(model_name)
 
-    async def model_exists(self, model_name: str, controller: Controller = None) -> bool:
+    async def model_exists(
+        self, model_name: str, controller: Controller = None
+    ) -> bool:
         """
         Check if model exists
 
@@ -343,7 +346,7 @@ class Libjuju:
         db_dict: dict = None,
         progress_timeout: float = None,
         total_timeout: float = None,
-        series: str = "xenial",
+        series: str = "bionic",
         wait: bool = True,
     ) -> (Machine, bool):
         """
@@ -556,7 +559,7 @@ class Libjuju:
         controller = await self.get_controller()
         model = await self.get_model(controller, model_name)
         try:
-            await model.deploy(uri)
+            await model.deploy(uri, trust=True)
             if wait:
                 await JujuModelWatcher.wait_for_model(model, timeout=timeout)
                 self.log.debug("All units active in model {}".format(model_name))
@@ -564,6 +567,146 @@ class Libjuju:
             await self.disconnect_model(model)
             await self.disconnect_controller(controller)
 
+    async def add_unit(
+        self,
+        application_name: str,
+        model_name: str,
+        machine_id: str,
+        db_dict: dict = None,
+        progress_timeout: float = None,
+        total_timeout: float = None,
+    ):
+        """Add unit
+
+        :param: application_name:   Application name
+        :param: model_name:         Model name
+        :param: machine_id          Machine id
+        :param: db_dict:            Dictionary with data of the DB to write the updates
+        :param: progress_timeout:   Maximum time between two updates in the model
+        :param: total_timeout:      Timeout for the entity to be active
+
+        :return: None
+        """
+
+        model = None
+        controller = await self.get_controller()
+        try:
+            model = await self.get_model(controller, model_name)
+            application = self._get_application(model, application_name)
+
+            if application is not None:
+
+                # Checks if the given machine id in the model,
+                # otherwise function raises an error
+                _machine, _series = self._get_machine_info(model, machine_id)
+
+                self.log.debug(
+                    "Adding unit (machine {}) to application {} in model ~{}".format(
+                        machine_id, application_name, model_name
+                    )
+                )
+
+                await application.add_unit(to=machine_id)
+
+                await JujuModelWatcher.wait_for(
+                    model=model,
+                    entity=application,
+                    progress_timeout=progress_timeout,
+                    total_timeout=total_timeout,
+                    db_dict=db_dict,
+                    n2vc=self.n2vc,
+                    vca_id=self.vca_connection._vca_id,
+                )
+                self.log.debug(
+                    "Unit is added to application {} in model {}".format(
+                        application_name, model_name
+                    )
+                )
+            else:
+                raise JujuApplicationNotFound(
+                    "Application {} not exists".format(application_name)
+                )
+        finally:
+            if model:
+                await self.disconnect_model(model)
+            await self.disconnect_controller(controller)
+
+    async def destroy_unit(
+        self,
+        application_name: str,
+        model_name: str,
+        machine_id: str,
+        total_timeout: float = None,
+    ):
+        """Destroy unit
+
+        :param: application_name:   Application name
+        :param: model_name:         Model name
+        :param: machine_id          Machine id
+        :param: total_timeout:      Timeout for the entity to be active
+
+        :return: None
+        """
+
+        model = None
+        controller = await self.get_controller()
+        try:
+            model = await self.get_model(controller, model_name)
+            application = self._get_application(model, application_name)
+
+            if application is None:
+                raise JujuApplicationNotFound(
+                    "Application not found: {} (model={})".format(
+                        application_name, model_name
+                    )
+                )
+
+            unit = self._get_unit(application, machine_id)
+            if not unit:
+                raise JujuError(
+                    "A unit with machine id {} not in available units".format(
+                        machine_id
+                    )
+                )
+
+            unit_name = unit.name
+
+            self.log.debug(
+                "Destroying unit {} from application {} in model {}".format(
+                    unit_name, application_name, model_name
+                )
+            )
+            await application.destroy_unit(unit_name)
+
+            self.log.debug(
+                "Waiting for unit {} to be destroyed in application {} (model={})...".format(
+                    unit_name, application_name, model_name
+                )
+            )
+
+            # TODO: Add functionality in the Juju watcher to replace this kind of blocks
+            if total_timeout is None:
+                total_timeout = 3600
+            end = time.time() + total_timeout
+            while time.time() < end:
+                if not self._get_unit(application, machine_id):
+                    self.log.debug(
+                        "The unit {} was destroyed in application {} (model={}) ".format(
+                            unit_name, application_name, model_name
+                        )
+                    )
+                    return
+                await asyncio.sleep(5)
+            self.log.debug(
+                "Unit {} is destroyed from application {} in model {}".format(
+                    unit_name, application_name, model_name
+                )
+            )
+        finally:
+            if model:
+                await self.disconnect_model(model)
+            await self.disconnect_controller(controller)
+
     async def deploy_charm(
         self,
         application_name: str,
@@ -606,16 +749,10 @@ class Libjuju:
         model = await self.get_model(controller, model_name)
 
         try:
-            application = None
             if application_name not in model.applications:
 
                 if machine_id is not None:
-                    if machine_id not in model.machines:
-                        msg = "Machine {} not found in model".format(machine_id)
-                        self.log.error(msg=msg)
-                        raise JujuMachineNotFound(msg)
-                    machine = model.machines[machine_id]
-                    series = machine.series
+                    machine, series = self._get_machine_info(model, machine_id)
 
                 application = await model.deploy(
                     entity_url=path,
@@ -655,18 +792,143 @@ class Libjuju:
                 raise JujuApplicationExists(
                     "Application {} exists".format(application_name)
                 )
+        except juju.errors.JujuError as e:
+            if "already exists" in e.message:
+                raise JujuApplicationExists(
+                    "Application {} exists".format(application_name)
+                )
+            else:
+                raise e
         finally:
             await self.disconnect_model(model)
             await self.disconnect_controller(controller)
 
         return application
 
+    async def upgrade_charm(
+        self,
+        application_name: str,
+        path: str,
+        model_name: str,
+        total_timeout: float = None,
+        **kwargs,
+    ):
+        """Upgrade Charm
+
+        :param: application_name:   Application name
+        :param: model_name:         Model name
+        :param: path:               Local path to the charm
+        :param: total_timeout:      Timeout for the entity to be active
+
+        :return: (str, str): (output and status)
+        """
+
+        self.log.debug(
+            "Upgrading charm {} in model {} from path {}".format(
+                application_name, model_name, path
+            )
+        )
+
+        await self.resolve_application(
+            model_name=model_name, application_name=application_name
+        )
+
+        # Get controller
+        controller = await self.get_controller()
+
+        # Get model
+        model = await self.get_model(controller, model_name)
+
+        try:
+            # Get application
+            application = self._get_application(
+                model,
+                application_name=application_name,
+            )
+            if application is None:
+                raise JujuApplicationNotFound(
+                    "Cannot find application {} to upgrade".format(application_name)
+                )
+
+            await application.refresh(path=path)
+
+            self.log.debug(
+                "Wait until charm upgrade is completed for application {} (model={})".format(
+                    application_name, model_name
+                )
+            )
+
+            await JujuModelWatcher.ensure_units_idle(
+                model=model, application=application
+            )
+
+            if application.status == "error":
+                error_message = "Unknown"
+                for unit in application.units:
+                    if (
+                        unit.workload_status == "error"
+                        and unit.workload_status_message != ""
+                    ):
+                        error_message = unit.workload_status_message
+
+                message = "Application {} failed update in {}: {}".format(
+                    application_name, model_name, error_message
+                )
+                self.log.error(message)
+                raise JujuError(message=message)
+
+            self.log.debug(
+                "Application {} is ready in model {}".format(
+                    application_name, model_name
+                )
+            )
+
+        finally:
+            await self.disconnect_model(model)
+            await self.disconnect_controller(controller)
+
+        return application
+
+    async def resolve_application(self, model_name: str, application_name: str):
+
+        controller = await self.get_controller()
+        model = await self.get_model(controller, model_name)
+
+        try:
+            application = self._get_application(
+                model,
+                application_name=application_name,
+            )
+            if application is None:
+                raise JujuApplicationNotFound(
+                    "Cannot find application {} to resolve".format(application_name)
+                )
+
+            while application.status == "error":
+                for unit in application.units:
+                    if unit.workload_status == "error":
+                        self.log.debug(
+                            "Model {}, Application {}, Unit {} in error state, resolving".format(
+                                model_name, application_name, unit.entity_id
+                            )
+                        )
+                        try:
+                            await unit.resolved(retry=False)
+                        except Exception:
+                            pass
+
+                await asyncio.sleep(1)
+
+        finally:
+            await self.disconnect_model(model)
+            await self.disconnect_controller(controller)
+
     async def scale_application(
-            self,
-            model_name: str,
-            application_name: str,
-            scale: int = 1,
-            total_timeout: float = None,
+        self,
+        model_name: str,
+        application_name: str,
+        scale: int = 1,
+        total_timeout: float = None,
     ):
         """
         Scale application (K8s)
@@ -693,8 +955,7 @@ class Libjuju:
             await application.scale(scale=scale)
             # Wait until application is scaled in model
             self.log.debug(
-                "Waiting for application {} to be scaled in model {}...".format
-                (
+                "Waiting for application {} to be scaled in model {}...".format(
                     application_name, model_name
                 )
             )
@@ -707,7 +968,9 @@ class Libjuju:
                 # wait until application unit count and scale count are equal.
                 # Because there is a delay before scaling triggers in Juju model.
                 if application_scale == scale:
-                    await JujuModelWatcher.wait_for_model(model=model, timeout=total_timeout)
+                    await JujuModelWatcher.wait_for_model(
+                        model=model, timeout=total_timeout
+                    )
                     self.log.debug(
                         "Application {} is scaled in model {}".format(
                             application_name, model_name
@@ -748,15 +1011,50 @@ class Libjuju:
         if model.applications and application_name in model.applications:
             return model.applications[application_name]
 
+    def _get_unit(self, application: Application, machine_id: str) -> Unit:
+        """Get unit
+
+        :param: application:        Application object
+        :param: machine_id:         Machine id
+
+        :return: Unit
+        """
+        unit = None
+        for u in application.units:
+            if u.machine_id == machine_id:
+                unit = u
+                break
+        return unit
+
+    def _get_machine_info(
+        self,
+        model,
+        machine_id: str,
+    ) -> (str, str):
+        """Get machine info
+
+        :param: model:          Model object
+        :param: machine_id:     Machine id
+
+        :return: (str, str): (machine, series)
+        """
+        if machine_id not in model.machines:
+            msg = "Machine {} not found in model".format(machine_id)
+            self.log.error(msg=msg)
+            raise JujuMachineNotFound(msg)
+        machine = model.machines[machine_id]
+        return machine, machine.series
+
     async def execute_action(
         self,
         application_name: str,
         model_name: str,
         action_name: str,
         db_dict: dict = None,
+        machine_id: str = None,
         progress_timeout: float = None,
         total_timeout: float = None,
-        **kwargs
+        **kwargs,
     ):
         """Execute action
 
@@ -764,6 +1062,7 @@ class Libjuju:
         :param: model_name:         Model name
         :param: action_name:        Name of the action
         :param: db_dict:            Dictionary with data of the DB to write the updates
+        :param: machine_id          Machine id
         :param: progress_timeout:   Maximum time between two updates in the model
         :param: total_timeout:      Timeout for the entity to be active
 
@@ -786,14 +1085,31 @@ class Libjuju:
             )
             if application is None:
                 raise JujuApplicationNotFound("Cannot execute action")
-
-            # Get leader unit
             # Racing condition:
             #   Ocassionally, self._get_leader_unit() will return None
             #   because the leader elected hook has not been triggered yet.
             #   Therefore, we are doing some retries. If it happens again,
             #   re-open bug 1236
-            unit = await self._get_leader_unit(application)
+            if machine_id is None:
+                unit = await self._get_leader_unit(application)
+                self.log.debug(
+                    "Action {} is being executed on the leader unit {}".format(
+                        action_name, unit.name
+                    )
+                )
+            else:
+                unit = self._get_unit(application, machine_id)
+                if not unit:
+                    raise JujuError(
+                        "A unit with machine id {} not in available units".format(
+                            machine_id
+                        )
+                    )
+                self.log.debug(
+                    "Action {} is being executed on {} unit".format(
+                        action_name, unit.name
+                    )
+                )
 
             actions = await application.get_actions()
 
@@ -918,7 +1234,7 @@ class Libjuju:
         # Add relation
         try:
             await model.add_relation(endpoint_1, endpoint_2)
-        except JujuAPIError as e:
+        except juju.errors.JujuAPIError as e:
             if "not found" in e.message:
                 self.log.warning("Relation not found: {}".format(e.message))
                 return
@@ -931,31 +1247,74 @@ 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):
+    async def destroy_model(self, model_name: str, total_timeout: float = 1800):
         """
         Destroy model
 
@@ -969,42 +1328,58 @@ class Libjuju:
             if not await self.model_exists(model_name, controller=controller):
                 return
 
-            model = await self.get_model(controller, model_name)
             self.log.debug("Destroying model {}".format(model_name))
-            uuid = model.info.uuid
 
+            model = await self.get_model(controller, model_name)
             # Destroy machines that are manually provisioned
             # and still are in pending state
             await self._destroy_pending_machines(model, only_manual=True)
-
-            # Disconnect model
             await self.disconnect_model(model)
 
-            await controller.destroy_model(uuid, force=True, max_wait=0)
+            await self._destroy_model(
+                model_name,
+                controller,
+                timeout=total_timeout,
+            )
+        except Exception as e:
+            if not await self.model_exists(model_name, controller=controller):
+                return
+            raise e
+        finally:
+            if model:
+                await self.disconnect_model(model)
+            await self.disconnect_controller(controller)
 
-            # Wait until model is destroyed
-            self.log.debug("Waiting for model {} to be destroyed...".format(model_name))
+    async def _destroy_model(
+        self, model_name: str, controller: Controller, timeout: float = 1800
+    ):
+        """
+        Destroy model from controller
 
-            if total_timeout is None:
-                total_timeout = 3600
-            end = time.time() + total_timeout
-            while time.time() < end:
-                models = await controller.list_models()
-                if model_name not in models:
-                    self.log.debug(
-                        "The model {} ({}) was destroyed".format(model_name, uuid)
-                    )
-                    return
+        :param: model: Model name to be removed
+        :param: controller: Controller object
+        :param: timeout: Timeout in seconds
+        """
+
+        async def _destroy_model_loop(model_name: str, controller: Controller):
+            while await self.model_exists(model_name, controller=controller):
+                await controller.destroy_model(
+                    model_name, destroy_storage=True, force=True, max_wait=0
+                )
                 await asyncio.sleep(5)
+
+        try:
+            await asyncio.wait_for(
+                _destroy_model_loop(model_name, controller), timeout=timeout
+            )
+        except asyncio.TimeoutError:
             raise Exception(
                 "Timeout waiting for model {} to be destroyed".format(model_name)
             )
-        except Exception as e:
-            if model:
-                await self.disconnect_model(model)
+        except juju.errors.JujuError as e:
+            if any("has been removed" in error for error in e.errors):
+                return
             raise e
-        finally:
-            await self.disconnect_controller(controller)
 
     async def destroy_application(
         self, model_name: str, application_name: str, total_timeout: float
@@ -1141,17 +1516,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)
 
@@ -1291,6 +1678,11 @@ class Libjuju:
         controller = await self.get_controller()
         try:
             await controller.remove_cloud(name)
+        except juju.errors.JujuError as e:
+            if len(e.errors) == 1 and f'cloud "{name}" not found' == e.errors[0]:
+                self.log.warning(f"Cloud {name} not found, so it could not be deleted.")
+            else:
+                raise e
         finally:
             await self.disconnect_controller(controller)
 
@@ -1324,3 +1716,27 @@ class Libjuju:
             return (await facade.Credential(params)).results
         finally:
             await self.disconnect_controller(controller)
+
+    async def check_application_exists(self, model_name, application_name) -> bool:
+        """Check application exists
+
+        :param: model_name:         Model Name
+        :param: application_name:   Application Name
+
+        :return: Boolean
+        """
+
+        model = None
+        controller = await self.get_controller()
+        try:
+            model = await self.get_model(controller, model_name)
+            self.log.debug(
+                "Checking if application {} exists in model {}".format(
+                    application_name, model_name
+                )
+            )
+            return self._get_application(model, application_name) is not None
+        finally:
+            if model:
+                await self.disconnect_model(model)
+            await self.disconnect_controller(controller)