9d9d08e12f3e96b6444e8ba79ab1924854a0ccc9
[osm/N2VC.git] / n2vc / libjuju.py
1 # Copyright 2020 Canonical Ltd.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import asyncio
16 import logging
17 import typing
18
19 import time
20
21 import juju.errors
22 from juju.model import Model
23 from juju.machine import Machine
24 from juju.application import Application
25 from juju.unit import Unit
26 from juju.client._definitions import (
27 FullStatus,
28 QueryApplicationOffersResults,
29 Cloud,
30 CloudCredential,
31 )
32 from juju.controller import Controller
33 from juju.client import client
34 from juju import tag
35
36 from n2vc.definitions import Offer, RelationEndpoint
37 from n2vc.juju_watcher import JujuModelWatcher
38 from n2vc.provisioner import AsyncSSHProvisioner
39 from n2vc.n2vc_conn import N2VCConnector
40 from n2vc.exceptions import (
41 JujuMachineNotFound,
42 JujuApplicationNotFound,
43 JujuLeaderUnitNotFound,
44 JujuActionNotFound,
45 JujuControllerFailedConnecting,
46 JujuApplicationExists,
47 JujuInvalidK8sConfiguration,
48 JujuError,
49 )
50 from n2vc.vca.cloud import Cloud as VcaCloud
51 from n2vc.vca.connection import Connection
52 from kubernetes.client.configuration import Configuration
53 from retrying_async import retry
54
55
56 RBAC_LABEL_KEY_NAME = "rbac-id"
57
58
59 class Libjuju:
60 def __init__(
61 self,
62 vca_connection: Connection,
63 loop: asyncio.AbstractEventLoop = None,
64 log: logging.Logger = None,
65 n2vc: N2VCConnector = None,
66 ):
67 """
68 Constructor
69
70 :param: vca_connection: n2vc.vca.connection object
71 :param: loop: Asyncio loop
72 :param: log: Logger
73 :param: n2vc: N2VC object
74 """
75
76 self.log = log or logging.getLogger("Libjuju")
77 self.n2vc = n2vc
78 self.vca_connection = vca_connection
79
80 self.loop = loop or asyncio.get_event_loop()
81 self.loop.set_exception_handler(self.handle_exception)
82 self.creating_model = asyncio.Lock(loop=self.loop)
83
84 if self.vca_connection.is_default:
85 self.health_check_task = self._create_health_check_task()
86
87 def _create_health_check_task(self):
88 return self.loop.create_task(self.health_check())
89
90 async def get_controller(self, timeout: float = 60.0) -> Controller:
91 """
92 Get controller
93
94 :param: timeout: Time in seconds to wait for controller to connect
95 """
96 controller = None
97 try:
98 controller = Controller()
99 await asyncio.wait_for(
100 controller.connect(
101 endpoint=self.vca_connection.data.endpoints,
102 username=self.vca_connection.data.user,
103 password=self.vca_connection.data.secret,
104 cacert=self.vca_connection.data.cacert,
105 ),
106 timeout=timeout,
107 )
108 if self.vca_connection.is_default:
109 endpoints = await controller.api_endpoints
110 if not all(
111 endpoint in self.vca_connection.endpoints for endpoint in endpoints
112 ):
113 await self.vca_connection.update_endpoints(endpoints)
114 return controller
115 except asyncio.CancelledError as e:
116 raise e
117 except Exception as e:
118 self.log.error(
119 "Failed connecting to controller: {}... {}".format(
120 self.vca_connection.data.endpoints, e
121 )
122 )
123 if controller:
124 await self.disconnect_controller(controller)
125
126 raise JujuControllerFailedConnecting(
127 f"Error connecting to Juju controller: {e}"
128 )
129
130 async def disconnect(self):
131 """Disconnect"""
132 # Cancel health check task
133 self.health_check_task.cancel()
134 self.log.debug("Libjuju disconnected!")
135
136 async def disconnect_model(self, model: Model):
137 """
138 Disconnect model
139
140 :param: model: Model that will be disconnected
141 """
142 await model.disconnect()
143
144 async def disconnect_controller(self, controller: Controller):
145 """
146 Disconnect controller
147
148 :param: controller: Controller that will be disconnected
149 """
150 if controller:
151 await controller.disconnect()
152
153 @retry(attempts=3, delay=5, timeout=None)
154 async def add_model(self, model_name: str, cloud: VcaCloud):
155 """
156 Create model
157
158 :param: model_name: Model name
159 :param: cloud: Cloud object
160 """
161
162 # Get controller
163 controller = await self.get_controller()
164 model = None
165 try:
166 # Block until other workers have finished model creation
167 while self.creating_model.locked():
168 await asyncio.sleep(0.1)
169
170 # Create the model
171 async with self.creating_model:
172 if await self.model_exists(model_name, controller=controller):
173 return
174 self.log.debug("Creating model {}".format(model_name))
175 model = await controller.add_model(
176 model_name,
177 config=self.vca_connection.data.model_config,
178 cloud_name=cloud.name,
179 credential_name=cloud.credential_name,
180 )
181 except juju.errors.JujuAPIError as e:
182 if "already exists" in e.message:
183 pass
184 else:
185 raise e
186 finally:
187 if model:
188 await self.disconnect_model(model)
189 await self.disconnect_controller(controller)
190
191 async def get_executed_actions(self, model_name: str) -> list:
192 """
193 Get executed/history of actions for a model.
194
195 :param: model_name: Model name, str.
196 :return: List of executed actions for a model.
197 """
198 model = None
199 executed_actions = []
200 controller = await self.get_controller()
201 try:
202 model = await self.get_model(controller, model_name)
203 # Get all unique action names
204 actions = {}
205 for application in model.applications:
206 application_actions = await self.get_actions(application, model_name)
207 actions.update(application_actions)
208 # Get status of all actions
209 for application_action in actions:
210 app_action_status_list = await model.get_action_status(
211 name=application_action
212 )
213 for action_id, action_status in app_action_status_list.items():
214 executed_action = {
215 "id": action_id,
216 "action": application_action,
217 "status": action_status,
218 }
219 # Get action output by id
220 action_status = await model.get_action_output(executed_action["id"])
221 for k, v in action_status.items():
222 executed_action[k] = v
223 executed_actions.append(executed_action)
224 except Exception as e:
225 raise JujuError(
226 "Error in getting executed actions for model: {}. Error: {}".format(
227 model_name, str(e)
228 )
229 )
230 finally:
231 if model:
232 await self.disconnect_model(model)
233 await self.disconnect_controller(controller)
234 return executed_actions
235
236 async def get_application_configs(
237 self, model_name: str, application_name: str
238 ) -> dict:
239 """
240 Get available configs for an application.
241
242 :param: model_name: Model name, str.
243 :param: application_name: Application name, str.
244
245 :return: A dict which has key - action name, value - action description
246 """
247 model = None
248 application_configs = {}
249 controller = await self.get_controller()
250 try:
251 model = await self.get_model(controller, model_name)
252 application = self._get_application(
253 model, application_name=application_name
254 )
255 application_configs = await application.get_config()
256 except Exception as e:
257 raise JujuError(
258 "Error in getting configs for application: {} in model: {}. Error: {}".format(
259 application_name, model_name, str(e)
260 )
261 )
262 finally:
263 if model:
264 await self.disconnect_model(model)
265 await self.disconnect_controller(controller)
266 return application_configs
267
268 @retry(attempts=3, delay=5)
269 async def get_model(self, controller: Controller, model_name: str) -> Model:
270 """
271 Get model from controller
272
273 :param: controller: Controller
274 :param: model_name: Model name
275
276 :return: Model: The created Juju model object
277 """
278 return await controller.get_model(model_name)
279
280 async def model_exists(
281 self, model_name: str, controller: Controller = None
282 ) -> bool:
283 """
284 Check if model exists
285
286 :param: controller: Controller
287 :param: model_name: Model name
288
289 :return bool
290 """
291 need_to_disconnect = False
292
293 # Get controller if not passed
294 if not controller:
295 controller = await self.get_controller()
296 need_to_disconnect = True
297
298 # Check if model exists
299 try:
300 return model_name in await controller.list_models()
301 finally:
302 if need_to_disconnect:
303 await self.disconnect_controller(controller)
304
305 async def models_exist(self, model_names: [str]) -> (bool, list):
306 """
307 Check if models exists
308
309 :param: model_names: List of strings with model names
310
311 :return (bool, list[str]): (True if all models exists, List of model names that don't exist)
312 """
313 if not model_names:
314 raise Exception(
315 "model_names must be a non-empty array. Given value: {}".format(
316 model_names
317 )
318 )
319 non_existing_models = []
320 models = await self.list_models()
321 existing_models = list(set(models).intersection(model_names))
322 non_existing_models = list(set(model_names) - set(existing_models))
323
324 return (
325 len(non_existing_models) == 0,
326 non_existing_models,
327 )
328
329 async def get_model_status(self, model_name: str) -> FullStatus:
330 """
331 Get model status
332
333 :param: model_name: Model name
334
335 :return: Full status object
336 """
337 controller = await self.get_controller()
338 model = await self.get_model(controller, model_name)
339 try:
340 return await model.get_status()
341 finally:
342 await self.disconnect_model(model)
343 await self.disconnect_controller(controller)
344
345 async def create_machine(
346 self,
347 model_name: str,
348 machine_id: str = None,
349 db_dict: dict = None,
350 progress_timeout: float = None,
351 total_timeout: float = None,
352 series: str = "bionic",
353 wait: bool = True,
354 ) -> (Machine, bool):
355 """
356 Create machine
357
358 :param: model_name: Model name
359 :param: machine_id: Machine id
360 :param: db_dict: Dictionary with data of the DB to write the updates
361 :param: progress_timeout: Maximum time between two updates in the model
362 :param: total_timeout: Timeout for the entity to be active
363 :param: series: Series of the machine (xenial, bionic, focal, ...)
364 :param: wait: Wait until machine is ready
365
366 :return: (juju.machine.Machine, bool): Machine object and a boolean saying
367 if the machine is new or it already existed
368 """
369 new = False
370 machine = None
371
372 self.log.debug(
373 "Creating machine (id={}) in model: {}".format(machine_id, model_name)
374 )
375
376 # Get controller
377 controller = await self.get_controller()
378
379 # Get model
380 model = await self.get_model(controller, model_name)
381 try:
382 if machine_id is not None:
383 self.log.debug(
384 "Searching machine (id={}) in model {}".format(
385 machine_id, model_name
386 )
387 )
388
389 # Get machines from model and get the machine with machine_id if exists
390 machines = await model.get_machines()
391 if machine_id in machines:
392 self.log.debug(
393 "Machine (id={}) found in model {}".format(
394 machine_id, model_name
395 )
396 )
397 machine = machines[machine_id]
398 else:
399 raise JujuMachineNotFound("Machine {} not found".format(machine_id))
400
401 if machine is None:
402 self.log.debug("Creating a new machine in model {}".format(model_name))
403
404 # Create machine
405 machine = await model.add_machine(
406 spec=None, constraints=None, disks=None, series=series
407 )
408 new = True
409
410 # Wait until the machine is ready
411 self.log.debug(
412 "Wait until machine {} is ready in model {}".format(
413 machine.entity_id, model_name
414 )
415 )
416 if wait:
417 await JujuModelWatcher.wait_for(
418 model=model,
419 entity=machine,
420 progress_timeout=progress_timeout,
421 total_timeout=total_timeout,
422 db_dict=db_dict,
423 n2vc=self.n2vc,
424 vca_id=self.vca_connection._vca_id,
425 )
426 finally:
427 await self.disconnect_model(model)
428 await self.disconnect_controller(controller)
429
430 self.log.debug(
431 "Machine {} ready at {} in model {}".format(
432 machine.entity_id, machine.dns_name, model_name
433 )
434 )
435 return machine, new
436
437 async def provision_machine(
438 self,
439 model_name: str,
440 hostname: str,
441 username: str,
442 private_key_path: str,
443 db_dict: dict = None,
444 progress_timeout: float = None,
445 total_timeout: float = None,
446 ) -> str:
447 """
448 Manually provisioning of a machine
449
450 :param: model_name: Model name
451 :param: hostname: IP to access the machine
452 :param: username: Username to login to the machine
453 :param: private_key_path: Local path for the private key
454 :param: db_dict: Dictionary with data of the DB to write the updates
455 :param: progress_timeout: Maximum time between two updates in the model
456 :param: total_timeout: Timeout for the entity to be active
457
458 :return: (Entity): Machine id
459 """
460 self.log.debug(
461 "Provisioning machine. model: {}, hostname: {}, username: {}".format(
462 model_name, hostname, username
463 )
464 )
465
466 # Get controller
467 controller = await self.get_controller()
468
469 # Get model
470 model = await self.get_model(controller, model_name)
471
472 try:
473 # Get provisioner
474 provisioner = AsyncSSHProvisioner(
475 host=hostname,
476 user=username,
477 private_key_path=private_key_path,
478 log=self.log,
479 )
480
481 # Provision machine
482 params = await provisioner.provision_machine()
483
484 params.jobs = ["JobHostUnits"]
485
486 self.log.debug("Adding machine to model")
487 connection = model.connection()
488 client_facade = client.ClientFacade.from_connection(connection)
489
490 results = await client_facade.AddMachines(params=[params])
491 error = results.machines[0].error
492
493 if error:
494 msg = "Error adding machine: {}".format(error.message)
495 self.log.error(msg=msg)
496 raise ValueError(msg)
497
498 machine_id = results.machines[0].machine
499
500 self.log.debug("Installing Juju agent into machine {}".format(machine_id))
501 asyncio.ensure_future(
502 provisioner.install_agent(
503 connection=connection,
504 nonce=params.nonce,
505 machine_id=machine_id,
506 proxy=self.vca_connection.data.api_proxy,
507 series=params.series,
508 )
509 )
510
511 machine = None
512 for _ in range(10):
513 machine_list = await model.get_machines()
514 if machine_id in machine_list:
515 self.log.debug("Machine {} found in model!".format(machine_id))
516 machine = model.machines.get(machine_id)
517 break
518 await asyncio.sleep(2)
519
520 if machine is None:
521 msg = "Machine {} not found in model".format(machine_id)
522 self.log.error(msg=msg)
523 raise JujuMachineNotFound(msg)
524
525 self.log.debug(
526 "Wait until machine {} is ready in model {}".format(
527 machine.entity_id, model_name
528 )
529 )
530 await JujuModelWatcher.wait_for(
531 model=model,
532 entity=machine,
533 progress_timeout=progress_timeout,
534 total_timeout=total_timeout,
535 db_dict=db_dict,
536 n2vc=self.n2vc,
537 vca_id=self.vca_connection._vca_id,
538 )
539 except Exception as e:
540 raise e
541 finally:
542 await self.disconnect_model(model)
543 await self.disconnect_controller(controller)
544
545 self.log.debug(
546 "Machine provisioned {} in model {}".format(machine_id, model_name)
547 )
548
549 return machine_id
550
551 async def deploy(
552 self, uri: str, model_name: str, wait: bool = True, timeout: float = 3600
553 ):
554 """
555 Deploy bundle or charm: Similar to the juju CLI command `juju deploy`
556
557 :param: uri: Path or Charm Store uri in which the charm or bundle can be found
558 :param: model_name: Model name
559 :param: wait: Indicates whether to wait or not until all applications are active
560 :param: timeout: Time in seconds to wait until all applications are active
561 """
562 controller = await self.get_controller()
563 model = await self.get_model(controller, model_name)
564 try:
565 await model.deploy(uri, trust=True)
566 if wait:
567 await JujuModelWatcher.wait_for_model(model, timeout=timeout)
568 self.log.debug("All units active in model {}".format(model_name))
569 finally:
570 await self.disconnect_model(model)
571 await self.disconnect_controller(controller)
572
573 async def add_unit(
574 self,
575 application_name: str,
576 model_name: str,
577 machine_id: str,
578 db_dict: dict = None,
579 progress_timeout: float = None,
580 total_timeout: float = None,
581 ):
582 """Add unit
583
584 :param: application_name: Application name
585 :param: model_name: Model name
586 :param: machine_id Machine id
587 :param: db_dict: Dictionary with data of the DB to write the updates
588 :param: progress_timeout: Maximum time between two updates in the model
589 :param: total_timeout: Timeout for the entity to be active
590
591 :return: None
592 """
593
594 model = None
595 controller = await self.get_controller()
596 try:
597 model = await self.get_model(controller, model_name)
598 application = self._get_application(model, application_name)
599
600 if application is not None:
601
602 # Checks if the given machine id in the model,
603 # otherwise function raises an error
604 _machine, _series = self._get_machine_info(model, machine_id)
605
606 self.log.debug(
607 "Adding unit (machine {}) to application {} in model ~{}".format(
608 machine_id, application_name, model_name
609 )
610 )
611
612 await application.add_unit(to=machine_id)
613
614 await JujuModelWatcher.wait_for(
615 model=model,
616 entity=application,
617 progress_timeout=progress_timeout,
618 total_timeout=total_timeout,
619 db_dict=db_dict,
620 n2vc=self.n2vc,
621 vca_id=self.vca_connection._vca_id,
622 )
623 self.log.debug(
624 "Unit is added to application {} in model {}".format(
625 application_name, model_name
626 )
627 )
628 else:
629 raise JujuApplicationNotFound(
630 "Application {} not exists".format(application_name)
631 )
632 finally:
633 if model:
634 await self.disconnect_model(model)
635 await self.disconnect_controller(controller)
636
637 async def destroy_unit(
638 self,
639 application_name: str,
640 model_name: str,
641 machine_id: str,
642 total_timeout: float = None,
643 ):
644 """Destroy unit
645
646 :param: application_name: Application name
647 :param: model_name: Model name
648 :param: machine_id Machine id
649 :param: total_timeout: Timeout for the entity to be active
650
651 :return: None
652 """
653
654 model = None
655 controller = await self.get_controller()
656 try:
657 model = await self.get_model(controller, model_name)
658 application = self._get_application(model, application_name)
659
660 if application is None:
661 raise JujuApplicationNotFound(
662 "Application not found: {} (model={})".format(
663 application_name, model_name
664 )
665 )
666
667 unit = self._get_unit(application, machine_id)
668 if not unit:
669 raise JujuError(
670 "A unit with machine id {} not in available units".format(
671 machine_id
672 )
673 )
674
675 unit_name = unit.name
676
677 self.log.debug(
678 "Destroying unit {} from application {} in model {}".format(
679 unit_name, application_name, model_name
680 )
681 )
682 await application.destroy_unit(unit_name)
683
684 self.log.debug(
685 "Waiting for unit {} to be destroyed in application {} (model={})...".format(
686 unit_name, application_name, model_name
687 )
688 )
689
690 # TODO: Add functionality in the Juju watcher to replace this kind of blocks
691 if total_timeout is None:
692 total_timeout = 3600
693 end = time.time() + total_timeout
694 while time.time() < end:
695 if not self._get_unit(application, machine_id):
696 self.log.debug(
697 "The unit {} was destroyed in application {} (model={}) ".format(
698 unit_name, application_name, model_name
699 )
700 )
701 return
702 await asyncio.sleep(5)
703 self.log.debug(
704 "Unit {} is destroyed from application {} in model {}".format(
705 unit_name, application_name, model_name
706 )
707 )
708 finally:
709 if model:
710 await self.disconnect_model(model)
711 await self.disconnect_controller(controller)
712
713 async def deploy_charm(
714 self,
715 application_name: str,
716 path: str,
717 model_name: str,
718 machine_id: str,
719 db_dict: dict = None,
720 progress_timeout: float = None,
721 total_timeout: float = None,
722 config: dict = None,
723 series: str = None,
724 num_units: int = 1,
725 ):
726 """Deploy charm
727
728 :param: application_name: Application name
729 :param: path: Local path to the charm
730 :param: model_name: Model name
731 :param: machine_id ID of the machine
732 :param: db_dict: Dictionary with data of the DB to write the updates
733 :param: progress_timeout: Maximum time between two updates in the model
734 :param: total_timeout: Timeout for the entity to be active
735 :param: config: Config for the charm
736 :param: series: Series of the charm
737 :param: num_units: Number of units
738
739 :return: (juju.application.Application): Juju application
740 """
741 self.log.debug(
742 "Deploying charm {} to machine {} in model ~{}".format(
743 application_name, machine_id, model_name
744 )
745 )
746 self.log.debug("charm: {}".format(path))
747
748 # Get controller
749 controller = await self.get_controller()
750
751 # Get model
752 model = await self.get_model(controller, model_name)
753
754 try:
755 if application_name not in model.applications:
756
757 if machine_id is not None:
758 machine, series = self._get_machine_info(model, machine_id)
759
760 application = await model.deploy(
761 entity_url=path,
762 application_name=application_name,
763 channel="stable",
764 num_units=1,
765 series=series,
766 to=machine_id,
767 config=config,
768 )
769
770 self.log.debug(
771 "Wait until application {} is ready in model {}".format(
772 application_name, model_name
773 )
774 )
775 if num_units > 1:
776 for _ in range(num_units - 1):
777 m, _ = await self.create_machine(model_name, wait=False)
778 await application.add_unit(to=m.entity_id)
779
780 await JujuModelWatcher.wait_for(
781 model=model,
782 entity=application,
783 progress_timeout=progress_timeout,
784 total_timeout=total_timeout,
785 db_dict=db_dict,
786 n2vc=self.n2vc,
787 vca_id=self.vca_connection._vca_id,
788 )
789 self.log.debug(
790 "Application {} is ready in model {}".format(
791 application_name, model_name
792 )
793 )
794 else:
795 raise JujuApplicationExists(
796 "Application {} exists".format(application_name)
797 )
798 except juju.errors.JujuError as e:
799 if "already exists" in e.message:
800 raise JujuApplicationExists(
801 "Application {} exists".format(application_name)
802 )
803 else:
804 raise e
805 finally:
806 await self.disconnect_model(model)
807 await self.disconnect_controller(controller)
808
809 return application
810
811 async def upgrade_charm(
812 self,
813 application_name: str,
814 path: str,
815 model_name: str,
816 total_timeout: float = None,
817 **kwargs,
818 ):
819 """Upgrade Charm
820
821 :param: application_name: Application name
822 :param: model_name: Model name
823 :param: path: Local path to the charm
824 :param: total_timeout: Timeout for the entity to be active
825
826 :return: (str, str): (output and status)
827 """
828
829 self.log.debug(
830 "Upgrading charm {} in model {} from path {}".format(
831 application_name, model_name, path
832 )
833 )
834
835 await self.resolve_application(
836 model_name=model_name, application_name=application_name
837 )
838
839 # Get controller
840 controller = await self.get_controller()
841
842 # Get model
843 model = await self.get_model(controller, model_name)
844
845 try:
846 # Get application
847 application = self._get_application(
848 model,
849 application_name=application_name,
850 )
851 if application is None:
852 raise JujuApplicationNotFound(
853 "Cannot find application {} to upgrade".format(application_name)
854 )
855
856 await application.refresh(path=path)
857
858 self.log.debug(
859 "Wait until charm upgrade is completed for application {} (model={})".format(
860 application_name, model_name
861 )
862 )
863
864 await JujuModelWatcher.ensure_units_idle(
865 model=model, application=application
866 )
867
868 if application.status == "error":
869 error_message = "Unknown"
870 for unit in application.units:
871 if (
872 unit.workload_status == "error"
873 and unit.workload_status_message != ""
874 ):
875 error_message = unit.workload_status_message
876
877 message = "Application {} failed update in {}: {}".format(
878 application_name, model_name, error_message
879 )
880 self.log.error(message)
881 raise JujuError(message=message)
882
883 self.log.debug(
884 "Application {} is ready in model {}".format(
885 application_name, model_name
886 )
887 )
888
889 finally:
890 await self.disconnect_model(model)
891 await self.disconnect_controller(controller)
892
893 return application
894
895 async def resolve_application(self, model_name: str, application_name: str):
896
897 controller = await self.get_controller()
898 model = await self.get_model(controller, model_name)
899
900 try:
901 application = self._get_application(
902 model,
903 application_name=application_name,
904 )
905 if application is None:
906 raise JujuApplicationNotFound(
907 "Cannot find application {} to resolve".format(application_name)
908 )
909
910 while application.status == "error":
911 for unit in application.units:
912 if unit.workload_status == "error":
913 self.log.debug(
914 "Model {}, Application {}, Unit {} in error state, resolving".format(
915 model_name, application_name, unit.entity_id
916 )
917 )
918 try:
919 await unit.resolved(retry=False)
920 except Exception:
921 pass
922
923 await asyncio.sleep(1)
924
925 finally:
926 await self.disconnect_model(model)
927 await self.disconnect_controller(controller)
928
929 async def scale_application(
930 self,
931 model_name: str,
932 application_name: str,
933 scale: int = 1,
934 total_timeout: float = None,
935 ):
936 """
937 Scale application (K8s)
938
939 :param: model_name: Model name
940 :param: application_name: Application name
941 :param: scale: Scale to which to set this application
942 :param: total_timeout: Timeout for the entity to be active
943 """
944
945 model = None
946 controller = await self.get_controller()
947 try:
948 model = await self.get_model(controller, model_name)
949
950 self.log.debug(
951 "Scaling application {} in model {}".format(
952 application_name, model_name
953 )
954 )
955 application = self._get_application(model, application_name)
956 if application is None:
957 raise JujuApplicationNotFound("Cannot scale application")
958 await application.scale(scale=scale)
959 # Wait until application is scaled in model
960 self.log.debug(
961 "Waiting for application {} to be scaled in model {}...".format(
962 application_name, model_name
963 )
964 )
965 if total_timeout is None:
966 total_timeout = 1800
967 end = time.time() + total_timeout
968 while time.time() < end:
969 application_scale = self._get_application_count(model, application_name)
970 # Before calling wait_for_model function,
971 # wait until application unit count and scale count are equal.
972 # Because there is a delay before scaling triggers in Juju model.
973 if application_scale == scale:
974 await JujuModelWatcher.wait_for_model(
975 model=model, timeout=total_timeout
976 )
977 self.log.debug(
978 "Application {} is scaled in model {}".format(
979 application_name, model_name
980 )
981 )
982 return
983 await asyncio.sleep(5)
984 raise Exception(
985 "Timeout waiting for application {} in model {} to be scaled".format(
986 application_name, model_name
987 )
988 )
989 finally:
990 if model:
991 await self.disconnect_model(model)
992 await self.disconnect_controller(controller)
993
994 def _get_application_count(self, model: Model, application_name: str) -> int:
995 """Get number of units of the application
996
997 :param: model: Model object
998 :param: application_name: Application name
999
1000 :return: int (or None if application doesn't exist)
1001 """
1002 application = self._get_application(model, application_name)
1003 if application is not None:
1004 return len(application.units)
1005
1006 def _get_application(self, model: Model, application_name: str) -> Application:
1007 """Get application
1008
1009 :param: model: Model object
1010 :param: application_name: Application name
1011
1012 :return: juju.application.Application (or None if it doesn't exist)
1013 """
1014 if model.applications and application_name in model.applications:
1015 return model.applications[application_name]
1016
1017 def _get_unit(self, application: Application, machine_id: str) -> Unit:
1018 """Get unit
1019
1020 :param: application: Application object
1021 :param: machine_id: Machine id
1022
1023 :return: Unit
1024 """
1025 unit = None
1026 for u in application.units:
1027 if u.machine_id == machine_id:
1028 unit = u
1029 break
1030 return unit
1031
1032 def _get_machine_info(
1033 self,
1034 model,
1035 machine_id: str,
1036 ) -> (str, str):
1037 """Get machine info
1038
1039 :param: model: Model object
1040 :param: machine_id: Machine id
1041
1042 :return: (str, str): (machine, series)
1043 """
1044 if machine_id not in model.machines:
1045 msg = "Machine {} not found in model".format(machine_id)
1046 self.log.error(msg=msg)
1047 raise JujuMachineNotFound(msg)
1048 machine = model.machines[machine_id]
1049 return machine, machine.series
1050
1051 async def execute_action(
1052 self,
1053 application_name: str,
1054 model_name: str,
1055 action_name: str,
1056 db_dict: dict = None,
1057 machine_id: str = None,
1058 progress_timeout: float = None,
1059 total_timeout: float = None,
1060 **kwargs,
1061 ):
1062 """Execute action
1063
1064 :param: application_name: Application name
1065 :param: model_name: Model name
1066 :param: action_name: Name of the action
1067 :param: db_dict: Dictionary with data of the DB to write the updates
1068 :param: machine_id Machine id
1069 :param: progress_timeout: Maximum time between two updates in the model
1070 :param: total_timeout: Timeout for the entity to be active
1071
1072 :return: (str, str): (output and status)
1073 """
1074 self.log.debug(
1075 "Executing action {} using params {}".format(action_name, kwargs)
1076 )
1077 # Get controller
1078 controller = await self.get_controller()
1079
1080 # Get model
1081 model = await self.get_model(controller, model_name)
1082
1083 try:
1084 # Get application
1085 application = self._get_application(
1086 model,
1087 application_name=application_name,
1088 )
1089 if application is None:
1090 raise JujuApplicationNotFound("Cannot execute action")
1091 # Racing condition:
1092 # Ocassionally, self._get_leader_unit() will return None
1093 # because the leader elected hook has not been triggered yet.
1094 # Therefore, we are doing some retries. If it happens again,
1095 # re-open bug 1236
1096 if machine_id is None:
1097 unit = await self._get_leader_unit(application)
1098 self.log.debug(
1099 "Action {} is being executed on the leader unit {}".format(
1100 action_name, unit.name
1101 )
1102 )
1103 else:
1104 unit = self._get_unit(application, machine_id)
1105 if not unit:
1106 raise JujuError(
1107 "A unit with machine id {} not in available units".format(
1108 machine_id
1109 )
1110 )
1111 self.log.debug(
1112 "Action {} is being executed on {} unit".format(
1113 action_name, unit.name
1114 )
1115 )
1116
1117 actions = await application.get_actions()
1118
1119 if action_name not in actions:
1120 raise JujuActionNotFound(
1121 "Action {} not in available actions".format(action_name)
1122 )
1123
1124 action = await unit.run_action(action_name, **kwargs)
1125
1126 self.log.debug(
1127 "Wait until action {} is completed in application {} (model={})".format(
1128 action_name, application_name, model_name
1129 )
1130 )
1131 await JujuModelWatcher.wait_for(
1132 model=model,
1133 entity=action,
1134 progress_timeout=progress_timeout,
1135 total_timeout=total_timeout,
1136 db_dict=db_dict,
1137 n2vc=self.n2vc,
1138 vca_id=self.vca_connection._vca_id,
1139 )
1140
1141 output = await model.get_action_output(action_uuid=action.entity_id)
1142 status = await model.get_action_status(uuid_or_prefix=action.entity_id)
1143 status = (
1144 status[action.entity_id] if action.entity_id in status else "failed"
1145 )
1146
1147 self.log.debug(
1148 "Action {} completed with status {} in application {} (model={})".format(
1149 action_name, action.status, application_name, model_name
1150 )
1151 )
1152 finally:
1153 await self.disconnect_model(model)
1154 await self.disconnect_controller(controller)
1155
1156 return output, status
1157
1158 async def get_actions(self, application_name: str, model_name: str) -> dict:
1159 """Get list of actions
1160
1161 :param: application_name: Application name
1162 :param: model_name: Model name
1163
1164 :return: Dict with this format
1165 {
1166 "action_name": "Description of the action",
1167 ...
1168 }
1169 """
1170 self.log.debug(
1171 "Getting list of actions for application {}".format(application_name)
1172 )
1173
1174 # Get controller
1175 controller = await self.get_controller()
1176
1177 # Get model
1178 model = await self.get_model(controller, model_name)
1179
1180 try:
1181 # Get application
1182 application = self._get_application(
1183 model,
1184 application_name=application_name,
1185 )
1186
1187 # Return list of actions
1188 return await application.get_actions()
1189
1190 finally:
1191 # Disconnect from model and controller
1192 await self.disconnect_model(model)
1193 await self.disconnect_controller(controller)
1194
1195 async def get_metrics(self, model_name: str, application_name: str) -> dict:
1196 """Get the metrics collected by the VCA.
1197
1198 :param model_name The name or unique id of the network service
1199 :param application_name The name of the application
1200 """
1201 if not model_name or not application_name:
1202 raise Exception("model_name and application_name must be non-empty strings")
1203 metrics = {}
1204 controller = await self.get_controller()
1205 model = await self.get_model(controller, model_name)
1206 try:
1207 application = self._get_application(model, application_name)
1208 if application is not None:
1209 metrics = await application.get_metrics()
1210 finally:
1211 self.disconnect_model(model)
1212 self.disconnect_controller(controller)
1213 return metrics
1214
1215 async def add_relation(
1216 self,
1217 model_name: str,
1218 endpoint_1: str,
1219 endpoint_2: str,
1220 ):
1221 """Add relation
1222
1223 :param: model_name: Model name
1224 :param: endpoint_1 First endpoint name
1225 ("app:endpoint" format or directly the saas name)
1226 :param: endpoint_2: Second endpoint name (^ same format)
1227 """
1228
1229 self.log.debug("Adding relation: {} -> {}".format(endpoint_1, endpoint_2))
1230
1231 # Get controller
1232 controller = await self.get_controller()
1233
1234 # Get model
1235 model = await self.get_model(controller, model_name)
1236
1237 # Add relation
1238 try:
1239 await model.add_relation(endpoint_1, endpoint_2)
1240 except juju.errors.JujuAPIError as e:
1241 if "not found" in e.message:
1242 self.log.warning("Relation not found: {}".format(e.message))
1243 return
1244 if "already exists" in e.message:
1245 self.log.warning("Relation already exists: {}".format(e.message))
1246 return
1247 # another exception, raise it
1248 raise e
1249 finally:
1250 await self.disconnect_model(model)
1251 await self.disconnect_controller(controller)
1252
1253 async def offer(self, endpoint: RelationEndpoint) -> Offer:
1254 """
1255 Create an offer from a RelationEndpoint
1256
1257 :param: endpoint: Relation endpoint
1258
1259 :return: Offer object
1260 """
1261 model_name = endpoint.model_name
1262 offer_name = f"{endpoint.application_name}-{endpoint.endpoint_name}"
1263 controller = await self.get_controller()
1264 model = None
1265 try:
1266 model = await self.get_model(controller, model_name)
1267 await model.create_offer(endpoint.endpoint, offer_name=offer_name)
1268 offer_list = await self._list_offers(model_name, offer_name=offer_name)
1269 if offer_list:
1270 return Offer(offer_list[0].offer_url)
1271 else:
1272 raise Exception("offer was not created")
1273 except juju.errors.JujuError as e:
1274 if "application offer already exists" not in e.message:
1275 raise e
1276 finally:
1277 if model:
1278 self.disconnect_model(model)
1279 self.disconnect_controller(controller)
1280
1281 async def consume(
1282 self,
1283 model_name: str,
1284 offer: Offer,
1285 provider_libjuju: "Libjuju",
1286 ) -> str:
1287 """
1288 Consumes a remote offer in the model. Relations can be created later using "juju relate".
1289
1290 :param: model_name: Model name
1291 :param: offer: Offer object to consume
1292 :param: provider_libjuju: Libjuju object of the provider endpoint
1293
1294 :raises ParseError if there's a problem parsing the offer_url
1295 :raises JujuError if remote offer includes and endpoint
1296 :raises JujuAPIError if the operation is not successful
1297
1298 :returns: Saas name. It is the application name in the model that reference the remote application.
1299 """
1300 saas_name = f'{offer.name}-{offer.model_name.replace("-", "")}'
1301 if offer.vca_id:
1302 saas_name = f"{saas_name}-{offer.vca_id}"
1303 controller = await self.get_controller()
1304 model = None
1305 provider_controller = None
1306 try:
1307 model = await controller.get_model(model_name)
1308 provider_controller = await provider_libjuju.get_controller()
1309 await model.consume(
1310 offer.url, application_alias=saas_name, controller=provider_controller
1311 )
1312 return saas_name
1313 finally:
1314 if model:
1315 await self.disconnect_model(model)
1316 if provider_controller:
1317 await provider_libjuju.disconnect_controller(provider_controller)
1318 await self.disconnect_controller(controller)
1319
1320 async def destroy_model(self, model_name: str, total_timeout: float = 1800):
1321 """
1322 Destroy model
1323
1324 :param: model_name: Model name
1325 :param: total_timeout: Timeout
1326 """
1327
1328 controller = await self.get_controller()
1329 model = None
1330 try:
1331 if not await self.model_exists(model_name, controller=controller):
1332 self.log.warn(f"Model {model_name} doesn't exist")
1333 return
1334
1335 self.log.debug(f"Getting model {model_name} to be destroyed")
1336 model = await self.get_model(controller, model_name)
1337 self.log.debug(f"Destroying manual machines in model {model_name}")
1338 # Destroy machines that are manually provisioned
1339 # and still are in pending state
1340 await self._destroy_pending_machines(model, only_manual=True)
1341 await self.disconnect_model(model)
1342
1343 await self._destroy_model(
1344 model_name,
1345 controller,
1346 timeout=total_timeout,
1347 )
1348 except Exception as e:
1349 if not await self.model_exists(model_name, controller=controller):
1350 self.log.warn(
1351 f"Failed deleting model {model_name}: model doesn't exist"
1352 )
1353 return
1354 self.log.warn(f"Failed deleting model {model_name}: {e}")
1355 raise e
1356 finally:
1357 if model:
1358 await self.disconnect_model(model)
1359 await self.disconnect_controller(controller)
1360
1361 async def _destroy_model(
1362 self, model_name: str, controller: Controller, timeout: float = 1800
1363 ):
1364 """
1365 Destroy model from controller
1366
1367 :param: model: Model name to be removed
1368 :param: controller: Controller object
1369 :param: timeout: Timeout in seconds
1370 """
1371 self.log.debug(f"Destroying model {model_name}")
1372
1373 async def _destroy_model_loop(model_name: str, controller: Controller):
1374 while await self.model_exists(model_name, controller=controller):
1375 await controller.destroy_model(
1376 model_name, destroy_storage=True, force=True, max_wait=0
1377 )
1378 await asyncio.sleep(5)
1379
1380 try:
1381 await asyncio.wait_for(
1382 _destroy_model_loop(model_name, controller), timeout=timeout
1383 )
1384 except asyncio.TimeoutError:
1385 raise Exception(
1386 "Timeout waiting for model {} to be destroyed".format(model_name)
1387 )
1388 except juju.errors.JujuError as e:
1389 if any("has been removed" in error for error in e.errors):
1390 return
1391 raise e
1392
1393 async def destroy_application(
1394 self, model_name: str, application_name: str, total_timeout: float
1395 ):
1396 """
1397 Destroy application
1398
1399 :param: model_name: Model name
1400 :param: application_name: Application name
1401 :param: total_timeout: Timeout
1402 """
1403
1404 controller = await self.get_controller()
1405 model = None
1406
1407 try:
1408 model = await self.get_model(controller, model_name)
1409 self.log.debug(
1410 "Destroying application {} in model {}".format(
1411 application_name, model_name
1412 )
1413 )
1414 application = self._get_application(model, application_name)
1415 if application:
1416 await application.destroy()
1417 else:
1418 self.log.warning("Application not found: {}".format(application_name))
1419
1420 self.log.debug(
1421 "Waiting for application {} to be destroyed in model {}...".format(
1422 application_name, model_name
1423 )
1424 )
1425 if total_timeout is None:
1426 total_timeout = 3600
1427 end = time.time() + total_timeout
1428 while time.time() < end:
1429 if not self._get_application(model, application_name):
1430 self.log.debug(
1431 "The application {} was destroyed in model {} ".format(
1432 application_name, model_name
1433 )
1434 )
1435 return
1436 await asyncio.sleep(5)
1437 raise Exception(
1438 "Timeout waiting for application {} to be destroyed in model {}".format(
1439 application_name, model_name
1440 )
1441 )
1442 finally:
1443 if model is not None:
1444 await self.disconnect_model(model)
1445 await self.disconnect_controller(controller)
1446
1447 async def _destroy_pending_machines(self, model: Model, only_manual: bool = False):
1448 """
1449 Destroy pending machines in a given model
1450
1451 :param: only_manual: Bool that indicates only manually provisioned
1452 machines should be destroyed (if True), or that
1453 all pending machines should be destroyed
1454 """
1455 status = await model.get_status()
1456 for machine_id in status.machines:
1457 machine_status = status.machines[machine_id]
1458 if machine_status.agent_status.status == "pending":
1459 if only_manual and not machine_status.instance_id.startswith("manual:"):
1460 break
1461 machine = model.machines[machine_id]
1462 await machine.destroy(force=True)
1463
1464 async def configure_application(
1465 self, model_name: str, application_name: str, config: dict = None
1466 ):
1467 """Configure application
1468
1469 :param: model_name: Model name
1470 :param: application_name: Application name
1471 :param: config: Config to apply to the charm
1472 """
1473 self.log.debug("Configuring application {}".format(application_name))
1474
1475 if config:
1476 controller = await self.get_controller()
1477 model = None
1478 try:
1479 model = await self.get_model(controller, model_name)
1480 application = self._get_application(
1481 model,
1482 application_name=application_name,
1483 )
1484 await application.set_config(config)
1485 finally:
1486 if model:
1487 await self.disconnect_model(model)
1488 await self.disconnect_controller(controller)
1489
1490 def handle_exception(self, loop, context):
1491 # All unhandled exceptions by libjuju are handled here.
1492 pass
1493
1494 async def health_check(self, interval: float = 300.0):
1495 """
1496 Health check to make sure controller and controller_model connections are OK
1497
1498 :param: interval: Time in seconds between checks
1499 """
1500 controller = None
1501 while True:
1502 try:
1503 controller = await self.get_controller()
1504 # self.log.debug("VCA is alive")
1505 except Exception as e:
1506 self.log.error("Health check to VCA failed: {}".format(e))
1507 finally:
1508 await self.disconnect_controller(controller)
1509 await asyncio.sleep(interval)
1510
1511 async def list_models(self, contains: str = None) -> [str]:
1512 """List models with certain names
1513
1514 :param: contains: String that is contained in model name
1515
1516 :retur: [models] Returns list of model names
1517 """
1518
1519 controller = await self.get_controller()
1520 try:
1521 models = await controller.list_models()
1522 if contains:
1523 models = [model for model in models if contains in model]
1524 return models
1525 finally:
1526 await self.disconnect_controller(controller)
1527
1528 async def _list_offers(
1529 self, model_name: str, offer_name: str = None
1530 ) -> QueryApplicationOffersResults:
1531 """
1532 List offers within a model
1533
1534 :param: model_name: Model name
1535 :param: offer_name: Offer name to filter.
1536
1537 :return: Returns application offers results in the model
1538 """
1539
1540 controller = await self.get_controller()
1541 try:
1542 offers = (await controller.list_offers(model_name)).results
1543 if offer_name:
1544 matching_offer = []
1545 for offer in offers:
1546 if offer.offer_name == offer_name:
1547 matching_offer.append(offer)
1548 break
1549 offers = matching_offer
1550 return offers
1551 finally:
1552 await self.disconnect_controller(controller)
1553
1554 async def add_k8s(
1555 self,
1556 name: str,
1557 rbac_id: str,
1558 token: str,
1559 client_cert_data: str,
1560 configuration: Configuration,
1561 storage_class: str,
1562 credential_name: str = None,
1563 ):
1564 """
1565 Add a Kubernetes cloud to the controller
1566
1567 Similar to the `juju add-k8s` command in the CLI
1568
1569 :param: name: Name for the K8s cloud
1570 :param: configuration: Kubernetes configuration object
1571 :param: storage_class: Storage Class to use in the cloud
1572 :param: credential_name: Storage Class to use in the cloud
1573 """
1574
1575 if not storage_class:
1576 raise Exception("storage_class must be a non-empty string")
1577 if not name:
1578 raise Exception("name must be a non-empty string")
1579 if not configuration:
1580 raise Exception("configuration must be provided")
1581
1582 endpoint = configuration.host
1583 credential = self.get_k8s_cloud_credential(
1584 configuration,
1585 client_cert_data,
1586 token,
1587 )
1588 credential.attrs[RBAC_LABEL_KEY_NAME] = rbac_id
1589 cloud = client.Cloud(
1590 type_="kubernetes",
1591 auth_types=[credential.auth_type],
1592 endpoint=endpoint,
1593 ca_certificates=[client_cert_data],
1594 config={
1595 "operator-storage": storage_class,
1596 "workload-storage": storage_class,
1597 },
1598 )
1599
1600 return await self.add_cloud(
1601 name, cloud, credential, credential_name=credential_name
1602 )
1603
1604 def get_k8s_cloud_credential(
1605 self,
1606 configuration: Configuration,
1607 client_cert_data: str,
1608 token: str = None,
1609 ) -> client.CloudCredential:
1610 attrs = {}
1611 # TODO: Test with AKS
1612 key = None # open(configuration.key_file, "r").read()
1613 username = configuration.username
1614 password = configuration.password
1615
1616 if client_cert_data:
1617 attrs["ClientCertificateData"] = client_cert_data
1618 if key:
1619 attrs["ClientKeyData"] = key
1620 if token:
1621 if username or password:
1622 raise JujuInvalidK8sConfiguration("Cannot set both token and user/pass")
1623 attrs["Token"] = token
1624
1625 auth_type = None
1626 if key:
1627 auth_type = "oauth2"
1628 if client_cert_data:
1629 auth_type = "oauth2withcert"
1630 if not token:
1631 raise JujuInvalidK8sConfiguration(
1632 "missing token for auth type {}".format(auth_type)
1633 )
1634 elif username:
1635 if not password:
1636 self.log.debug(
1637 "credential for user {} has empty password".format(username)
1638 )
1639 attrs["username"] = username
1640 attrs["password"] = password
1641 if client_cert_data:
1642 auth_type = "userpasswithcert"
1643 else:
1644 auth_type = "userpass"
1645 elif client_cert_data and token:
1646 auth_type = "certificate"
1647 else:
1648 raise JujuInvalidK8sConfiguration("authentication method not supported")
1649 return client.CloudCredential(auth_type=auth_type, attrs=attrs)
1650
1651 async def add_cloud(
1652 self,
1653 name: str,
1654 cloud: Cloud,
1655 credential: CloudCredential = None,
1656 credential_name: str = None,
1657 ) -> Cloud:
1658 """
1659 Add cloud to the controller
1660
1661 :param: name: Name of the cloud to be added
1662 :param: cloud: Cloud object
1663 :param: credential: CloudCredentials object for the cloud
1664 :param: credential_name: Credential name.
1665 If not defined, cloud of the name will be used.
1666 """
1667 controller = await self.get_controller()
1668 try:
1669 _ = await controller.add_cloud(name, cloud)
1670 if credential:
1671 await controller.add_credential(
1672 credential_name or name, credential=credential, cloud=name
1673 )
1674 # Need to return the object returned by the controller.add_cloud() function
1675 # I'm returning the original value now until this bug is fixed:
1676 # https://github.com/juju/python-libjuju/issues/443
1677 return cloud
1678 finally:
1679 await self.disconnect_controller(controller)
1680
1681 async def remove_cloud(self, name: str):
1682 """
1683 Remove cloud
1684
1685 :param: name: Name of the cloud to be removed
1686 """
1687 controller = await self.get_controller()
1688 try:
1689 await controller.remove_cloud(name)
1690 except juju.errors.JujuError as e:
1691 if len(e.errors) == 1 and f'cloud "{name}" not found' == e.errors[0]:
1692 self.log.warning(f"Cloud {name} not found, so it could not be deleted.")
1693 else:
1694 raise e
1695 finally:
1696 await self.disconnect_controller(controller)
1697
1698 @retry(attempts=20, delay=5, fallback=JujuLeaderUnitNotFound())
1699 async def _get_leader_unit(self, application: Application) -> Unit:
1700 unit = None
1701 for u in application.units:
1702 if await u.is_leader_from_status():
1703 unit = u
1704 break
1705 if not unit:
1706 raise Exception()
1707 return unit
1708
1709 async def get_cloud_credentials(self, cloud: Cloud) -> typing.List:
1710 """
1711 Get cloud credentials
1712
1713 :param: cloud: Cloud object. The returned credentials will be from this cloud.
1714
1715 :return: List of credentials object associated to the specified cloud
1716
1717 """
1718 controller = await self.get_controller()
1719 try:
1720 facade = client.CloudFacade.from_connection(controller.connection())
1721 cloud_cred_tag = tag.credential(
1722 cloud.name, self.vca_connection.data.user, cloud.credential_name
1723 )
1724 params = [client.Entity(cloud_cred_tag)]
1725 return (await facade.Credential(params)).results
1726 finally:
1727 await self.disconnect_controller(controller)
1728
1729 async def check_application_exists(self, model_name, application_name) -> bool:
1730 """Check application exists
1731
1732 :param: model_name: Model Name
1733 :param: application_name: Application Name
1734
1735 :return: Boolean
1736 """
1737
1738 model = None
1739 controller = await self.get_controller()
1740 try:
1741 model = await self.get_model(controller, model_name)
1742 self.log.debug(
1743 "Checking if application {} exists in model {}".format(
1744 application_name, model_name
1745 )
1746 )
1747 return self._get_application(model, application_name) is not None
1748 finally:
1749 if model:
1750 await self.disconnect_model(model)
1751 await self.disconnect_controller(controller)