Coverage for n2vc/libjuju.py: 74%
766 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-07 06:04 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-05-07 06:04 +0000
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.
15import asyncio
16import logging
17import os
18import typing
19import yaml
21import time
23import juju.errors
24from juju.bundle import BundleHandler
25from juju.model import Model
26from juju.machine import Machine
27from juju.application import Application
28from juju.unit import Unit
29from juju.url import URL
30from juju.version import DEFAULT_ARCHITECTURE
31from juju.client._definitions import (
32 FullStatus,
33 QueryApplicationOffersResults,
34 Cloud,
35 CloudCredential,
36)
37from juju.controller import Controller
38from juju.client import client
39from juju import tag
41from n2vc.definitions import Offer, RelationEndpoint
42from n2vc.juju_watcher import JujuModelWatcher
43from n2vc.provisioner import AsyncSSHProvisioner
44from n2vc.n2vc_conn import N2VCConnector
45from n2vc.exceptions import (
46 JujuMachineNotFound,
47 JujuApplicationNotFound,
48 JujuLeaderUnitNotFound,
49 JujuActionNotFound,
50 JujuControllerFailedConnecting,
51 JujuApplicationExists,
52 JujuInvalidK8sConfiguration,
53 JujuError,
54)
55from n2vc.vca.cloud import Cloud as VcaCloud
56from n2vc.vca.connection import Connection
57from kubernetes.client.configuration import Configuration
58from retrying_async import retry
61RBAC_LABEL_KEY_NAME = "rbac-id"
64@asyncio.coroutine
65def retry_callback(attempt, exc, args, kwargs, delay=0.5, *, loop):
66 # Specifically overridden from upstream implementation so it can
67 # continue to work with Python 3.10
68 yield from asyncio.sleep(attempt * delay)
69 return retry
72class Libjuju:
73 def __init__(
74 self,
75 vca_connection: Connection,
76 log: logging.Logger = None,
77 n2vc: N2VCConnector = None,
78 ):
79 """
80 Constructor
82 :param: vca_connection: n2vc.vca.connection object
83 :param: log: Logger
84 :param: n2vc: N2VC object
85 """
87 self.log = log or logging.getLogger("Libjuju")
88 self.n2vc = n2vc
89 self.vca_connection = vca_connection
91 self.creating_model = asyncio.Lock()
93 if self.vca_connection.is_default:
94 self.health_check_task = self._create_health_check_task()
96 def _create_health_check_task(self):
97 return asyncio.get_event_loop().create_task(self.health_check())
99 async def get_controller(self, timeout: float = 60.0) -> Controller:
100 """
101 Get controller
103 :param: timeout: Time in seconds to wait for controller to connect
104 """
105 controller = None
106 try:
107 controller = Controller()
108 await asyncio.wait_for(
109 controller.connect(
110 endpoint=self.vca_connection.data.endpoints,
111 username=self.vca_connection.data.user,
112 password=self.vca_connection.data.secret,
113 cacert=self.vca_connection.data.cacert,
114 ),
115 timeout=timeout,
116 )
117 if self.vca_connection.is_default:
118 endpoints = await controller.api_endpoints
119 if not all(
120 endpoint in self.vca_connection.endpoints for endpoint in endpoints
121 ):
122 await self.vca_connection.update_endpoints(endpoints)
123 return controller
124 except asyncio.CancelledError as e:
125 raise e
126 except Exception as e:
127 self.log.error(
128 "Failed connecting to controller: {}... {}".format(
129 self.vca_connection.data.endpoints, e
130 )
131 )
132 if controller:
133 await self.disconnect_controller(controller)
135 raise JujuControllerFailedConnecting(
136 f"Error connecting to Juju controller: {e}"
137 )
139 async def disconnect(self):
140 """Disconnect"""
141 # Cancel health check task
142 self.health_check_task.cancel()
143 self.log.debug("Libjuju disconnected!")
145 async def disconnect_model(self, model: Model):
146 """
147 Disconnect model
149 :param: model: Model that will be disconnected
150 """
151 await model.disconnect()
153 async def disconnect_controller(self, controller: Controller):
154 """
155 Disconnect controller
157 :param: controller: Controller that will be disconnected
158 """
159 if controller:
160 await controller.disconnect()
162 @retry(attempts=3, delay=5, timeout=None, callback=retry_callback)
163 async def add_model(self, model_name: str, cloud: VcaCloud):
164 """
165 Create model
167 :param: model_name: Model name
168 :param: cloud: Cloud object
169 """
171 # Get controller
172 controller = await self.get_controller()
173 model = None
174 try:
175 # Block until other workers have finished model creation
176 while self.creating_model.locked():
177 await asyncio.sleep(0.1)
179 # Create the model
180 async with self.creating_model:
181 if await self.model_exists(model_name, controller=controller):
182 return
183 self.log.debug("Creating model {}".format(model_name))
184 model = await controller.add_model(
185 model_name,
186 config=self.vca_connection.data.model_config,
187 cloud_name=cloud.name,
188 credential_name=cloud.credential_name,
189 )
190 except juju.errors.JujuAPIError as e:
191 if "already exists" in e.message:
192 pass
193 else:
194 raise e
195 finally:
196 if model:
197 await self.disconnect_model(model)
198 await self.disconnect_controller(controller)
200 async def get_executed_actions(self, model_name: str) -> list:
201 """
202 Get executed/history of actions for a model.
204 :param: model_name: Model name, str.
205 :return: List of executed actions for a model.
206 """
207 model = None
208 executed_actions = []
209 controller = await self.get_controller()
210 try:
211 model = await self.get_model(controller, model_name)
212 # Get all unique action names
213 actions = {}
214 for application in model.applications:
215 application_actions = await self.get_actions(application, model_name)
216 actions.update(application_actions)
217 # Get status of all actions
218 for application_action in actions:
219 app_action_status_list = await model.get_action_status(
220 name=application_action
221 )
222 for action_id, action_status in app_action_status_list.items():
223 executed_action = {
224 "id": action_id,
225 "action": application_action,
226 "status": action_status,
227 }
228 # Get action output by id
229 action_status = await model.get_action_output(executed_action["id"])
230 for k, v in action_status.items():
231 executed_action[k] = v
232 executed_actions.append(executed_action)
233 except Exception as e:
234 raise JujuError(
235 "Error in getting executed actions for model: {}. Error: {}".format(
236 model_name, str(e)
237 )
238 )
239 finally:
240 if model:
241 await self.disconnect_model(model)
242 await self.disconnect_controller(controller)
243 return executed_actions
245 async def get_application_configs(
246 self, model_name: str, application_name: str
247 ) -> dict:
248 """
249 Get available configs for an application.
251 :param: model_name: Model name, str.
252 :param: application_name: Application name, str.
254 :return: A dict which has key - action name, value - action description
255 """
256 model = None
257 application_configs = {}
258 controller = await self.get_controller()
259 try:
260 model = await self.get_model(controller, model_name)
261 application = self._get_application(
262 model, application_name=application_name
263 )
264 application_configs = await application.get_config()
265 except Exception as e:
266 raise JujuError(
267 "Error in getting configs for application: {} in model: {}. Error: {}".format(
268 application_name, model_name, str(e)
269 )
270 )
271 finally:
272 if model:
273 await self.disconnect_model(model)
274 await self.disconnect_controller(controller)
275 return application_configs
277 @retry(attempts=3, delay=5, callback=retry_callback)
278 async def get_model(self, controller: Controller, model_name: str) -> Model:
279 """
280 Get model from controller
282 :param: controller: Controller
283 :param: model_name: Model name
285 :return: Model: The created Juju model object
286 """
287 return await controller.get_model(model_name)
289 async def model_exists(
290 self, model_name: str, controller: Controller = None
291 ) -> bool:
292 """
293 Check if model exists
295 :param: controller: Controller
296 :param: model_name: Model name
298 :return bool
299 """
300 need_to_disconnect = False
302 # Get controller if not passed
303 if not controller:
304 controller = await self.get_controller()
305 need_to_disconnect = True
307 # Check if model exists
308 try:
309 return model_name in await controller.list_models()
310 finally:
311 if need_to_disconnect:
312 await self.disconnect_controller(controller)
314 async def models_exist(self, model_names: [str]) -> (bool, list):
315 """
316 Check if models exists
318 :param: model_names: List of strings with model names
320 :return (bool, list[str]): (True if all models exists, List of model names that don't exist)
321 """
322 if not model_names:
323 raise Exception(
324 "model_names must be a non-empty array. Given value: {}".format(
325 model_names
326 )
327 )
328 non_existing_models = []
329 models = await self.list_models()
330 existing_models = list(set(models).intersection(model_names))
331 non_existing_models = list(set(model_names) - set(existing_models))
333 return (
334 len(non_existing_models) == 0,
335 non_existing_models,
336 )
338 async def get_model_status(self, model_name: str) -> FullStatus:
339 """
340 Get model status
342 :param: model_name: Model name
344 :return: Full status object
345 """
346 controller = await self.get_controller()
347 model = await self.get_model(controller, model_name)
348 try:
349 return await model.get_status()
350 finally:
351 await self.disconnect_model(model)
352 await self.disconnect_controller(controller)
354 async def create_machine(
355 self,
356 model_name: str,
357 machine_id: str = None,
358 db_dict: dict = None,
359 progress_timeout: float = None,
360 total_timeout: float = None,
361 series: str = "bionic",
362 wait: bool = True,
363 ) -> (Machine, bool):
364 """
365 Create machine
367 :param: model_name: Model name
368 :param: machine_id: Machine id
369 :param: db_dict: Dictionary with data of the DB to write the updates
370 :param: progress_timeout: Maximum time between two updates in the model
371 :param: total_timeout: Timeout for the entity to be active
372 :param: series: Series of the machine (xenial, bionic, focal, ...)
373 :param: wait: Wait until machine is ready
375 :return: (juju.machine.Machine, bool): Machine object and a boolean saying
376 if the machine is new or it already existed
377 """
378 new = False
379 machine = None
381 self.log.debug(
382 "Creating machine (id={}) in model: {}".format(machine_id, model_name)
383 )
385 # Get controller
386 controller = await self.get_controller()
388 # Get model
389 model = await self.get_model(controller, model_name)
390 try:
391 if machine_id is not None:
392 self.log.debug(
393 "Searching machine (id={}) in model {}".format(
394 machine_id, model_name
395 )
396 )
398 # Get machines from model and get the machine with machine_id if exists
399 machines = await model.get_machines()
400 if machine_id in machines:
401 self.log.debug(
402 "Machine (id={}) found in model {}".format(
403 machine_id, model_name
404 )
405 )
406 machine = machines[machine_id]
407 else:
408 raise JujuMachineNotFound("Machine {} not found".format(machine_id))
410 if machine is None:
411 self.log.debug("Creating a new machine in model {}".format(model_name))
413 # Create machine
414 machine = await model.add_machine(
415 spec=None, constraints=None, disks=None, series=series
416 )
417 new = True
419 # Wait until the machine is ready
420 self.log.debug(
421 "Wait until machine {} is ready in model {}".format(
422 machine.entity_id, model_name
423 )
424 )
425 if wait:
426 await JujuModelWatcher.wait_for(
427 model=model,
428 entity=machine,
429 progress_timeout=progress_timeout,
430 total_timeout=total_timeout,
431 db_dict=db_dict,
432 n2vc=self.n2vc,
433 vca_id=self.vca_connection._vca_id,
434 )
435 finally:
436 await self.disconnect_model(model)
437 await self.disconnect_controller(controller)
439 self.log.debug(
440 "Machine {} ready at {} in model {}".format(
441 machine.entity_id, machine.dns_name, model_name
442 )
443 )
444 return machine, new
446 async def provision_machine(
447 self,
448 model_name: str,
449 hostname: str,
450 username: str,
451 private_key_path: str,
452 db_dict: dict = None,
453 progress_timeout: float = None,
454 total_timeout: float = None,
455 ) -> str:
456 """
457 Manually provisioning of a machine
459 :param: model_name: Model name
460 :param: hostname: IP to access the machine
461 :param: username: Username to login to the machine
462 :param: private_key_path: Local path for the private key
463 :param: db_dict: Dictionary with data of the DB to write the updates
464 :param: progress_timeout: Maximum time between two updates in the model
465 :param: total_timeout: Timeout for the entity to be active
467 :return: (Entity): Machine id
468 """
469 self.log.debug(
470 "Provisioning machine. model: {}, hostname: {}, username: {}".format(
471 model_name, hostname, username
472 )
473 )
475 # Get controller
476 controller = await self.get_controller()
478 # Get model
479 model = await self.get_model(controller, model_name)
481 try:
482 # Get provisioner
483 provisioner = AsyncSSHProvisioner(
484 host=hostname,
485 user=username,
486 private_key_path=private_key_path,
487 log=self.log,
488 )
490 # Provision machine
491 params = await provisioner.provision_machine()
493 params.jobs = ["JobHostUnits"]
495 self.log.debug("Adding machine to model")
496 connection = model.connection()
497 client_facade = client.ClientFacade.from_connection(connection)
499 results = await client_facade.AddMachines(params=[params])
500 error = results.machines[0].error
502 if error:
503 msg = "Error adding machine: {}".format(error.message)
504 self.log.error(msg=msg)
505 raise ValueError(msg)
507 machine_id = results.machines[0].machine
509 self.log.debug("Installing Juju agent into machine {}".format(machine_id))
510 asyncio.ensure_future(
511 provisioner.install_agent(
512 connection=connection,
513 nonce=params.nonce,
514 machine_id=machine_id,
515 proxy=self.vca_connection.data.api_proxy,
516 series=params.series,
517 )
518 )
520 machine = None
521 for _ in range(10):
522 machine_list = await model.get_machines()
523 if machine_id in machine_list:
524 self.log.debug("Machine {} found in model!".format(machine_id))
525 machine = model.machines.get(machine_id)
526 break
527 await asyncio.sleep(2)
529 if machine is None:
530 msg = "Machine {} not found in model".format(machine_id)
531 self.log.error(msg=msg)
532 raise JujuMachineNotFound(msg)
534 self.log.debug(
535 "Wait until machine {} is ready in model {}".format(
536 machine.entity_id, model_name
537 )
538 )
539 await JujuModelWatcher.wait_for(
540 model=model,
541 entity=machine,
542 progress_timeout=progress_timeout,
543 total_timeout=total_timeout,
544 db_dict=db_dict,
545 n2vc=self.n2vc,
546 vca_id=self.vca_connection._vca_id,
547 )
548 except Exception as e:
549 raise e
550 finally:
551 await self.disconnect_model(model)
552 await self.disconnect_controller(controller)
554 self.log.debug(
555 "Machine provisioned {} in model {}".format(machine_id, model_name)
556 )
558 return machine_id
560 async def deploy(
561 self,
562 uri: str,
563 model_name: str,
564 wait: bool = True,
565 timeout: float = 3600,
566 instantiation_params: dict = None,
567 ):
568 """
569 Deploy bundle or charm: Similar to the juju CLI command `juju deploy`
571 :param uri: Path or Charm Store uri in which the charm or bundle can be found
572 :param model_name: Model name
573 :param wait: Indicates whether to wait or not until all applications are active
574 :param timeout: Time in seconds to wait until all applications are active
575 :param instantiation_params: To be applied as overlay bundle over primary bundle.
576 """
577 controller = await self.get_controller()
578 model = await self.get_model(controller, model_name)
579 overlays = []
580 try:
581 await self._validate_instantiation_params(uri, model, instantiation_params)
582 overlays = self._get_overlays(model_name, instantiation_params)
583 await model.deploy(uri, trust=True, overlays=overlays)
584 if wait:
585 await JujuModelWatcher.wait_for_model(model, timeout=timeout)
586 self.log.debug("All units active in model {}".format(model_name))
587 finally:
588 self._remove_overlay_file(overlays)
589 await self.disconnect_model(model)
590 await self.disconnect_controller(controller)
592 async def _validate_instantiation_params(
593 self, uri: str, model, instantiation_params: dict
594 ) -> None:
595 """Checks if all the applications in instantiation_params
596 exist ins the original bundle.
598 Raises:
599 JujuApplicationNotFound if there is an invalid app in
600 the instantiation params.
601 """
602 overlay_apps = self._get_apps_in_instantiation_params(instantiation_params)
603 if not overlay_apps:
604 return
605 original_apps = await self._get_apps_in_original_bundle(uri, model)
606 if not all(app in original_apps for app in overlay_apps):
607 raise JujuApplicationNotFound(
608 "Cannot find application {} in original bundle {}".format(
609 overlay_apps, original_apps
610 )
611 )
613 async def _get_apps_in_original_bundle(self, uri: str, model) -> set:
614 """Bundle is downloaded in BundleHandler.fetch_plan.
615 That method takes care of opening and exception handling.
617 Resolve method gets all the information regarding the channel,
618 track, revision, type, source.
620 Returns:
621 Set with the names of the applications in original bundle.
622 """
623 url = URL.parse(uri)
624 architecture = DEFAULT_ARCHITECTURE # only AMD64 is allowed
625 res = await model.deploy_types[str(url.schema)].resolve(
626 url, architecture, entity_url=uri
627 )
628 handler = BundleHandler(model, trusted=True, forced=False)
629 await handler.fetch_plan(url, res.origin)
630 return handler.applications
632 def _get_apps_in_instantiation_params(self, instantiation_params: dict) -> list:
633 """Extract applications key in instantiation params.
635 Returns:
636 List with the names of the applications in instantiation params.
638 Raises:
639 JujuError if applications key is not found.
640 """
641 if not instantiation_params:
642 return []
643 try:
644 return [key for key in instantiation_params.get("applications")]
645 except Exception as e:
646 raise JujuError("Invalid overlay format. {}".format(str(e)))
648 def _get_overlays(self, model_name: str, instantiation_params: dict) -> list:
649 """Creates a temporary overlay file which includes the instantiation params.
650 Only one overlay file is created.
652 Returns:
653 List with one overlay filename. Empty list if there are no instantiation params.
654 """
655 if not instantiation_params:
656 return []
657 file_name = model_name + "-overlay.yaml"
658 self._write_overlay_file(file_name, instantiation_params)
659 return [file_name]
661 def _write_overlay_file(self, file_name: str, instantiation_params: dict) -> None:
662 with open(file_name, "w") as file:
663 yaml.dump(instantiation_params, file)
665 def _remove_overlay_file(self, overlay: list) -> None:
666 """Overlay contains either one or zero file names."""
667 if not overlay:
668 return
669 try:
670 filename = overlay[0]
671 os.remove(filename)
672 except OSError as e:
673 self.log.warning(
674 "Overlay file {} could not be removed: {}".format(filename, e)
675 )
677 async def add_unit(
678 self,
679 application_name: str,
680 model_name: str,
681 machine_id: str,
682 db_dict: dict = None,
683 progress_timeout: float = None,
684 total_timeout: float = None,
685 ):
686 """Add unit
688 :param: application_name: Application name
689 :param: model_name: Model name
690 :param: machine_id Machine id
691 :param: db_dict: Dictionary with data of the DB to write the updates
692 :param: progress_timeout: Maximum time between two updates in the model
693 :param: total_timeout: Timeout for the entity to be active
695 :return: None
696 """
698 model = None
699 controller = await self.get_controller()
700 try:
701 model = await self.get_model(controller, model_name)
702 application = self._get_application(model, application_name)
704 if application is not None:
705 # Checks if the given machine id in the model,
706 # otherwise function raises an error
707 _machine, _series = self._get_machine_info(model, machine_id)
709 self.log.debug(
710 "Adding unit (machine {}) to application {} in model ~{}".format(
711 machine_id, application_name, model_name
712 )
713 )
715 await application.add_unit(to=machine_id)
717 await JujuModelWatcher.wait_for(
718 model=model,
719 entity=application,
720 progress_timeout=progress_timeout,
721 total_timeout=total_timeout,
722 db_dict=db_dict,
723 n2vc=self.n2vc,
724 vca_id=self.vca_connection._vca_id,
725 )
726 self.log.debug(
727 "Unit is added to application {} in model {}".format(
728 application_name, model_name
729 )
730 )
731 else:
732 raise JujuApplicationNotFound(
733 "Application {} not exists".format(application_name)
734 )
735 finally:
736 if model:
737 await self.disconnect_model(model)
738 await self.disconnect_controller(controller)
740 async def destroy_unit(
741 self,
742 application_name: str,
743 model_name: str,
744 machine_id: str,
745 total_timeout: float = None,
746 ):
747 """Destroy unit
749 :param: application_name: Application name
750 :param: model_name: Model name
751 :param: machine_id Machine id
752 :param: total_timeout: Timeout for the entity to be active
754 :return: None
755 """
757 model = None
758 controller = await self.get_controller()
759 try:
760 model = await self.get_model(controller, model_name)
761 application = self._get_application(model, application_name)
763 if application is None:
764 raise JujuApplicationNotFound(
765 "Application not found: {} (model={})".format(
766 application_name, model_name
767 )
768 )
770 unit = self._get_unit(application, machine_id)
771 if not unit:
772 raise JujuError(
773 "A unit with machine id {} not in available units".format(
774 machine_id
775 )
776 )
778 unit_name = unit.name
780 self.log.debug(
781 "Destroying unit {} from application {} in model {}".format(
782 unit_name, application_name, model_name
783 )
784 )
785 await application.destroy_unit(unit_name)
787 self.log.debug(
788 "Waiting for unit {} to be destroyed in application {} (model={})...".format(
789 unit_name, application_name, model_name
790 )
791 )
793 # TODO: Add functionality in the Juju watcher to replace this kind of blocks
794 if total_timeout is None:
795 total_timeout = 3600
796 end = time.time() + total_timeout
797 while time.time() < end:
798 if not self._get_unit(application, machine_id):
799 self.log.debug(
800 "The unit {} was destroyed in application {} (model={}) ".format(
801 unit_name, application_name, model_name
802 )
803 )
804 return
805 await asyncio.sleep(5)
806 self.log.debug(
807 "Unit {} is destroyed from application {} in model {}".format(
808 unit_name, application_name, model_name
809 )
810 )
811 finally:
812 if model:
813 await self.disconnect_model(model)
814 await self.disconnect_controller(controller)
816 async def deploy_charm(
817 self,
818 application_name: str,
819 path: str,
820 model_name: str,
821 machine_id: str,
822 db_dict: dict = None,
823 progress_timeout: float = None,
824 total_timeout: float = None,
825 config: dict = None,
826 series: str = None,
827 num_units: int = 1,
828 ):
829 """Deploy charm
831 :param: application_name: Application name
832 :param: path: Local path to the charm
833 :param: model_name: Model name
834 :param: machine_id ID of the machine
835 :param: db_dict: Dictionary with data of the DB to write the updates
836 :param: progress_timeout: Maximum time between two updates in the model
837 :param: total_timeout: Timeout for the entity to be active
838 :param: config: Config for the charm
839 :param: series: Series of the charm
840 :param: num_units: Number of units
842 :return: (juju.application.Application): Juju application
843 """
844 self.log.debug(
845 "Deploying charm {} to machine {} in model ~{}".format(
846 application_name, machine_id, model_name
847 )
848 )
849 self.log.debug("charm: {}".format(path))
851 # Get controller
852 controller = await self.get_controller()
854 # Get model
855 model = await self.get_model(controller, model_name)
857 try:
858 if application_name not in model.applications:
859 if machine_id is not None:
860 machine, series = self._get_machine_info(model, machine_id)
862 application = await model.deploy(
863 entity_url=path,
864 application_name=application_name,
865 channel="stable",
866 num_units=1,
867 series=series,
868 to=machine_id,
869 config=config,
870 )
872 self.log.debug(
873 "Wait until application {} is ready in model {}".format(
874 application_name, model_name
875 )
876 )
877 if num_units > 1:
878 for _ in range(num_units - 1):
879 m, _ = await self.create_machine(model_name, wait=False)
880 await application.add_unit(to=m.entity_id)
882 await JujuModelWatcher.wait_for(
883 model=model,
884 entity=application,
885 progress_timeout=progress_timeout,
886 total_timeout=total_timeout,
887 db_dict=db_dict,
888 n2vc=self.n2vc,
889 vca_id=self.vca_connection._vca_id,
890 )
891 self.log.debug(
892 "Application {} is ready in model {}".format(
893 application_name, model_name
894 )
895 )
896 else:
897 raise JujuApplicationExists(
898 "Application {} exists".format(application_name)
899 )
900 except juju.errors.JujuError as e:
901 if "already exists" in e.message:
902 raise JujuApplicationExists(
903 "Application {} exists".format(application_name)
904 )
905 else:
906 raise e
907 finally:
908 await self.disconnect_model(model)
909 await self.disconnect_controller(controller)
911 return application
913 async def upgrade_charm(
914 self,
915 application_name: str,
916 path: str,
917 model_name: str,
918 total_timeout: float = None,
919 **kwargs,
920 ):
921 """Upgrade Charm
923 :param: application_name: Application name
924 :param: model_name: Model name
925 :param: path: Local path to the charm
926 :param: total_timeout: Timeout for the entity to be active
928 :return: (str, str): (output and status)
929 """
931 self.log.debug(
932 "Upgrading charm {} in model {} from path {}".format(
933 application_name, model_name, path
934 )
935 )
937 await self.resolve_application(
938 model_name=model_name, application_name=application_name
939 )
941 # Get controller
942 controller = await self.get_controller()
944 # Get model
945 model = await self.get_model(controller, model_name)
947 try:
948 # Get application
949 application = self._get_application(
950 model,
951 application_name=application_name,
952 )
953 if application is None:
954 raise JujuApplicationNotFound(
955 "Cannot find application {} to upgrade".format(application_name)
956 )
958 await application.refresh(path=path)
960 self.log.debug(
961 "Wait until charm upgrade is completed for application {} (model={})".format(
962 application_name, model_name
963 )
964 )
966 await JujuModelWatcher.ensure_units_idle(
967 model=model, application=application
968 )
970 if application.status == "error":
971 error_message = "Unknown"
972 for unit in application.units:
973 if (
974 unit.workload_status == "error"
975 and unit.workload_status_message != "" # pylint: disable=E1101
976 ):
977 error_message = (
978 unit.workload_status_message # pylint: disable=E1101
979 )
981 message = "Application {} failed update in {}: {}".format(
982 application_name, model_name, error_message
983 )
984 self.log.error(message)
985 raise JujuError(message=message)
987 self.log.debug(
988 "Application {} is ready in model {}".format(
989 application_name, model_name
990 )
991 )
993 finally:
994 await self.disconnect_model(model)
995 await self.disconnect_controller(controller)
997 return application
999 async def resolve_application(self, model_name: str, application_name: str):
1000 controller = await self.get_controller()
1001 model = await self.get_model(controller, model_name)
1003 try:
1004 application = self._get_application(
1005 model,
1006 application_name=application_name,
1007 )
1008 if application is None:
1009 raise JujuApplicationNotFound(
1010 "Cannot find application {} to resolve".format(application_name)
1011 )
1013 while application.status == "error":
1014 for unit in application.units:
1015 if unit.workload_status == "error":
1016 self.log.debug(
1017 "Model {}, Application {}, Unit {} in error state, resolving".format(
1018 model_name, application_name, unit.entity_id
1019 )
1020 )
1021 try:
1022 await unit.resolved(retry=False) # pylint: disable=E1101
1023 except Exception:
1024 pass
1026 await asyncio.sleep(1)
1028 finally:
1029 await self.disconnect_model(model)
1030 await self.disconnect_controller(controller)
1032 async def resolve(self, model_name: str):
1033 controller = await self.get_controller()
1034 model = await self.get_model(controller, model_name)
1035 all_units_active = False
1036 try:
1037 while not all_units_active:
1038 all_units_active = True
1039 for application_name, application in model.applications.items():
1040 if application.status == "error":
1041 for unit in application.units:
1042 if unit.workload_status == "error":
1043 self.log.debug(
1044 "Model {}, Application {}, Unit {} in error state, resolving".format(
1045 model_name, application_name, unit.entity_id
1046 )
1047 )
1048 try:
1049 await unit.resolved(retry=False)
1050 all_units_active = False
1051 except Exception:
1052 pass
1054 if not all_units_active:
1055 await asyncio.sleep(5)
1056 finally:
1057 await self.disconnect_model(model)
1058 await self.disconnect_controller(controller)
1060 async def scale_application(
1061 self,
1062 model_name: str,
1063 application_name: str,
1064 scale: int = 1,
1065 total_timeout: float = None,
1066 ):
1067 """
1068 Scale application (K8s)
1070 :param: model_name: Model name
1071 :param: application_name: Application name
1072 :param: scale: Scale to which to set this application
1073 :param: total_timeout: Timeout for the entity to be active
1074 """
1076 model = None
1077 controller = await self.get_controller()
1078 try:
1079 model = await self.get_model(controller, model_name)
1081 self.log.debug(
1082 "Scaling application {} in model {}".format(
1083 application_name, model_name
1084 )
1085 )
1086 application = self._get_application(model, application_name)
1087 if application is None:
1088 raise JujuApplicationNotFound("Cannot scale application")
1089 await application.scale(scale=scale)
1090 # Wait until application is scaled in model
1091 self.log.debug(
1092 "Waiting for application {} to be scaled in model {}...".format(
1093 application_name, model_name
1094 )
1095 )
1096 if total_timeout is None:
1097 total_timeout = 1800
1098 end = time.time() + total_timeout
1099 while time.time() < end:
1100 application_scale = self._get_application_count(model, application_name)
1101 # Before calling wait_for_model function,
1102 # wait until application unit count and scale count are equal.
1103 # Because there is a delay before scaling triggers in Juju model.
1104 if application_scale == scale:
1105 await JujuModelWatcher.wait_for_model(
1106 model=model, timeout=total_timeout
1107 )
1108 self.log.debug(
1109 "Application {} is scaled in model {}".format(
1110 application_name, model_name
1111 )
1112 )
1113 return
1114 await asyncio.sleep(5)
1115 raise Exception(
1116 "Timeout waiting for application {} in model {} to be scaled".format(
1117 application_name, model_name
1118 )
1119 )
1120 finally:
1121 if model:
1122 await self.disconnect_model(model)
1123 await self.disconnect_controller(controller)
1125 def _get_application_count(self, model: Model, application_name: str) -> int:
1126 """Get number of units of the application
1128 :param: model: Model object
1129 :param: application_name: Application name
1131 :return: int (or None if application doesn't exist)
1132 """
1133 application = self._get_application(model, application_name)
1134 if application is not None:
1135 return len(application.units)
1137 def _get_application(self, model: Model, application_name: str) -> Application:
1138 """Get application
1140 :param: model: Model object
1141 :param: application_name: Application name
1143 :return: juju.application.Application (or None if it doesn't exist)
1144 """
1145 if model.applications and application_name in model.applications:
1146 return model.applications[application_name]
1148 def _get_unit(self, application: Application, machine_id: str) -> Unit:
1149 """Get unit
1151 :param: application: Application object
1152 :param: machine_id: Machine id
1154 :return: Unit
1155 """
1156 unit = None
1157 for u in application.units:
1158 if u.machine_id == machine_id:
1159 unit = u
1160 break
1161 return unit
1163 def _get_machine_info(
1164 self,
1165 model,
1166 machine_id: str,
1167 ) -> (str, str):
1168 """Get machine info
1170 :param: model: Model object
1171 :param: machine_id: Machine id
1173 :return: (str, str): (machine, series)
1174 """
1175 if machine_id not in model.machines:
1176 msg = "Machine {} not found in model".format(machine_id)
1177 self.log.error(msg=msg)
1178 raise JujuMachineNotFound(msg)
1179 machine = model.machines[machine_id]
1180 return machine, machine.series
1182 async def execute_action(
1183 self,
1184 application_name: str,
1185 model_name: str,
1186 action_name: str,
1187 db_dict: dict = None,
1188 machine_id: str = None,
1189 progress_timeout: float = None,
1190 total_timeout: float = None,
1191 **kwargs,
1192 ):
1193 """Execute action
1195 :param: application_name: Application name
1196 :param: model_name: Model name
1197 :param: action_name: Name of the action
1198 :param: db_dict: Dictionary with data of the DB to write the updates
1199 :param: machine_id Machine id
1200 :param: progress_timeout: Maximum time between two updates in the model
1201 :param: total_timeout: Timeout for the entity to be active
1203 :return: (str, str): (output and status)
1204 """
1205 self.log.debug(
1206 "Executing action {} using params {}".format(action_name, kwargs)
1207 )
1208 # Get controller
1209 controller = await self.get_controller()
1211 # Get model
1212 model = await self.get_model(controller, model_name)
1214 try:
1215 # Get application
1216 application = self._get_application(
1217 model,
1218 application_name=application_name,
1219 )
1220 if application is None:
1221 raise JujuApplicationNotFound("Cannot execute action")
1222 # Racing condition:
1223 # Ocassionally, self._get_leader_unit() will return None
1224 # because the leader elected hook has not been triggered yet.
1225 # Therefore, we are doing some retries. If it happens again,
1226 # re-open bug 1236
1227 if machine_id is None:
1228 unit = await self._get_leader_unit(application)
1229 self.log.debug(
1230 "Action {} is being executed on the leader unit {}".format(
1231 action_name, unit.name
1232 )
1233 )
1234 else:
1235 unit = self._get_unit(application, machine_id)
1236 if not unit:
1237 raise JujuError(
1238 "A unit with machine id {} not in available units".format(
1239 machine_id
1240 )
1241 )
1242 self.log.debug(
1243 "Action {} is being executed on {} unit".format(
1244 action_name, unit.name
1245 )
1246 )
1248 actions = await application.get_actions()
1250 if action_name not in actions:
1251 raise JujuActionNotFound(
1252 "Action {} not in available actions".format(action_name)
1253 )
1255 action = await unit.run_action(action_name, **kwargs)
1257 self.log.debug(
1258 "Wait until action {} is completed in application {} (model={})".format(
1259 action_name, application_name, model_name
1260 )
1261 )
1262 await JujuModelWatcher.wait_for(
1263 model=model,
1264 entity=action,
1265 progress_timeout=progress_timeout,
1266 total_timeout=total_timeout,
1267 db_dict=db_dict,
1268 n2vc=self.n2vc,
1269 vca_id=self.vca_connection._vca_id,
1270 )
1272 output = await model.get_action_output(action_uuid=action.entity_id)
1273 status = await model.get_action_status(uuid_or_prefix=action.entity_id)
1274 status = (
1275 status[action.entity_id] if action.entity_id in status else "failed"
1276 )
1278 self.log.debug(
1279 "Action {} completed with status {} in application {} (model={})".format(
1280 action_name, action.status, application_name, model_name
1281 )
1282 )
1283 finally:
1284 await self.disconnect_model(model)
1285 await self.disconnect_controller(controller)
1287 return output, status
1289 async def get_actions(self, application_name: str, model_name: str) -> dict:
1290 """Get list of actions
1292 :param: application_name: Application name
1293 :param: model_name: Model name
1295 :return: Dict with this format
1296 {
1297 "action_name": "Description of the action",
1298 ...
1299 }
1300 """
1301 self.log.debug(
1302 "Getting list of actions for application {}".format(application_name)
1303 )
1305 # Get controller
1306 controller = await self.get_controller()
1308 # Get model
1309 model = await self.get_model(controller, model_name)
1311 try:
1312 # Get application
1313 application = self._get_application(
1314 model,
1315 application_name=application_name,
1316 )
1318 # Return list of actions
1319 return await application.get_actions()
1321 finally:
1322 # Disconnect from model and controller
1323 await self.disconnect_model(model)
1324 await self.disconnect_controller(controller)
1326 async def get_metrics(self, model_name: str, application_name: str) -> dict:
1327 """Get the metrics collected by the VCA.
1329 :param model_name The name or unique id of the network service
1330 :param application_name The name of the application
1331 """
1332 if not model_name or not application_name:
1333 raise Exception("model_name and application_name must be non-empty strings")
1334 metrics = {}
1335 controller = await self.get_controller()
1336 model = await self.get_model(controller, model_name)
1337 try:
1338 application = self._get_application(model, application_name)
1339 if application is not None:
1340 metrics = await application.get_metrics()
1341 finally:
1342 self.disconnect_model(model)
1343 self.disconnect_controller(controller)
1344 return metrics
1346 async def add_relation(
1347 self,
1348 model_name: str,
1349 endpoint_1: str,
1350 endpoint_2: str,
1351 ):
1352 """Add relation
1354 :param: model_name: Model name
1355 :param: endpoint_1 First endpoint name
1356 ("app:endpoint" format or directly the saas name)
1357 :param: endpoint_2: Second endpoint name (^ same format)
1358 """
1360 self.log.debug("Adding relation: {} -> {}".format(endpoint_1, endpoint_2))
1362 # Get controller
1363 controller = await self.get_controller()
1365 # Get model
1366 model = await self.get_model(controller, model_name)
1368 # Add relation
1369 try:
1370 await model.add_relation(endpoint_1, endpoint_2)
1371 except juju.errors.JujuAPIError as e:
1372 if self._relation_is_not_found(e):
1373 self.log.warning("Relation not found: {}".format(e.message))
1374 return
1375 if self._relation_already_exist(e):
1376 self.log.warning("Relation already exists: {}".format(e.message))
1377 return
1378 # another exception, raise it
1379 raise e
1380 finally:
1381 await self.disconnect_model(model)
1382 await self.disconnect_controller(controller)
1384 def _relation_is_not_found(self, juju_error):
1385 text = "not found"
1386 return (text in juju_error.message) or (
1387 juju_error.error_code and text in juju_error.error_code
1388 )
1390 def _relation_already_exist(self, juju_error):
1391 text = "already exists"
1392 return (text in juju_error.message) or (
1393 juju_error.error_code and text in juju_error.error_code
1394 )
1396 async def offer(self, endpoint: RelationEndpoint) -> Offer:
1397 """
1398 Create an offer from a RelationEndpoint
1400 :param: endpoint: Relation endpoint
1402 :return: Offer object
1403 """
1404 model_name = endpoint.model_name
1405 offer_name = f"{endpoint.application_name}-{endpoint.endpoint_name}"
1406 controller = await self.get_controller()
1407 model = None
1408 try:
1409 model = await self.get_model(controller, model_name)
1410 await model.create_offer(endpoint.endpoint, offer_name=offer_name)
1411 offer_list = await self._list_offers(model_name, offer_name=offer_name)
1412 if offer_list:
1413 return Offer(offer_list[0].offer_url)
1414 else:
1415 raise Exception("offer was not created")
1416 except juju.errors.JujuError as e:
1417 if "application offer already exists" not in e.message:
1418 raise e
1419 finally:
1420 if model:
1421 self.disconnect_model(model)
1422 self.disconnect_controller(controller)
1424 async def consume(
1425 self,
1426 model_name: str,
1427 offer: Offer,
1428 provider_libjuju: "Libjuju",
1429 ) -> str:
1430 """
1431 Consumes a remote offer in the model. Relations can be created later using "juju relate".
1433 :param: model_name: Model name
1434 :param: offer: Offer object to consume
1435 :param: provider_libjuju: Libjuju object of the provider endpoint
1437 :raises ParseError if there's a problem parsing the offer_url
1438 :raises JujuError if remote offer includes and endpoint
1439 :raises JujuAPIError if the operation is not successful
1441 :returns: Saas name. It is the application name in the model that reference the remote application.
1442 """
1443 saas_name = f'{offer.name}-{offer.model_name.replace("-", "")}'
1444 if offer.vca_id:
1445 saas_name = f"{saas_name}-{offer.vca_id}"
1446 controller = await self.get_controller()
1447 model = None
1448 provider_controller = None
1449 try:
1450 model = await controller.get_model(model_name)
1451 provider_controller = await provider_libjuju.get_controller()
1452 await model.consume(
1453 offer.url, application_alias=saas_name, controller=provider_controller
1454 )
1455 return saas_name
1456 finally:
1457 if model:
1458 await self.disconnect_model(model)
1459 if provider_controller:
1460 await provider_libjuju.disconnect_controller(provider_controller)
1461 await self.disconnect_controller(controller)
1463 async def destroy_model(self, model_name: str, total_timeout: float = 1800):
1464 """
1465 Destroy model
1467 :param: model_name: Model name
1468 :param: total_timeout: Timeout
1469 """
1471 controller = await self.get_controller()
1472 model = None
1473 try:
1474 if not await self.model_exists(model_name, controller=controller):
1475 self.log.warn(f"Model {model_name} doesn't exist")
1476 return
1478 self.log.debug(f"Getting model {model_name} to be destroyed")
1479 model = await self.get_model(controller, model_name)
1480 self.log.debug(f"Destroying manual machines in model {model_name}")
1481 # Destroy machines that are manually provisioned
1482 # and still are in pending state
1483 await self._destroy_pending_machines(model, only_manual=True)
1484 await self.disconnect_model(model)
1486 await asyncio.wait_for(
1487 self._destroy_model(model_name, controller),
1488 timeout=total_timeout,
1489 )
1490 except Exception as e:
1491 if not await self.model_exists(model_name, controller=controller):
1492 self.log.warn(
1493 f"Failed deleting model {model_name}: model doesn't exist"
1494 )
1495 return
1496 self.log.warn(f"Failed deleting model {model_name}: {e}")
1497 raise e
1498 finally:
1499 if model:
1500 await self.disconnect_model(model)
1501 await self.disconnect_controller(controller)
1503 async def _destroy_model(
1504 self,
1505 model_name: str,
1506 controller: Controller,
1507 ):
1508 """
1509 Destroy model from controller
1511 :param: model: Model name to be removed
1512 :param: controller: Controller object
1513 :param: timeout: Timeout in seconds
1514 """
1515 self.log.debug(f"Destroying model {model_name}")
1517 async def _destroy_model_gracefully(model_name: str, controller: Controller):
1518 self.log.info(f"Gracefully deleting model {model_name}")
1519 resolved = False
1520 while model_name in await controller.list_models():
1521 if not resolved:
1522 await self.resolve(model_name)
1523 resolved = True
1524 await controller.destroy_model(model_name, destroy_storage=True)
1526 await asyncio.sleep(5)
1527 self.log.info(f"Model {model_name} deleted gracefully")
1529 async def _destroy_model_forcefully(model_name: str, controller: Controller):
1530 self.log.info(f"Forcefully deleting model {model_name}")
1531 while model_name in await controller.list_models():
1532 await controller.destroy_model(
1533 model_name, destroy_storage=True, force=True, max_wait=60
1534 )
1535 await asyncio.sleep(5)
1536 self.log.info(f"Model {model_name} deleted forcefully")
1538 try:
1539 try:
1540 await asyncio.wait_for(
1541 _destroy_model_gracefully(model_name, controller), timeout=120
1542 )
1543 except asyncio.TimeoutError:
1544 await _destroy_model_forcefully(model_name, controller)
1545 except juju.errors.JujuError as e:
1546 if any("has been removed" in error for error in e.errors):
1547 return
1548 if any("model not found" in error for error in e.errors):
1549 return
1550 raise e
1552 async def destroy_application(
1553 self, model_name: str, application_name: str, total_timeout: float
1554 ):
1555 """
1556 Destroy application
1558 :param: model_name: Model name
1559 :param: application_name: Application name
1560 :param: total_timeout: Timeout
1561 """
1563 controller = await self.get_controller()
1564 model = None
1566 try:
1567 model = await self.get_model(controller, model_name)
1568 self.log.debug(
1569 "Destroying application {} in model {}".format(
1570 application_name, model_name
1571 )
1572 )
1573 application = self._get_application(model, application_name)
1574 if application:
1575 await application.destroy()
1576 else:
1577 self.log.warning("Application not found: {}".format(application_name))
1579 self.log.debug(
1580 "Waiting for application {} to be destroyed in model {}...".format(
1581 application_name, model_name
1582 )
1583 )
1584 if total_timeout is None:
1585 total_timeout = 3600
1586 end = time.time() + total_timeout
1587 while time.time() < end:
1588 if not self._get_application(model, application_name):
1589 self.log.debug(
1590 "The application {} was destroyed in model {} ".format(
1591 application_name, model_name
1592 )
1593 )
1594 return
1595 await asyncio.sleep(5)
1596 raise Exception(
1597 "Timeout waiting for application {} to be destroyed in model {}".format(
1598 application_name, model_name
1599 )
1600 )
1601 finally:
1602 if model is not None:
1603 await self.disconnect_model(model)
1604 await self.disconnect_controller(controller)
1606 async def _destroy_pending_machines(self, model: Model, only_manual: bool = False):
1607 """
1608 Destroy pending machines in a given model
1610 :param: only_manual: Bool that indicates only manually provisioned
1611 machines should be destroyed (if True), or that
1612 all pending machines should be destroyed
1613 """
1614 status = await model.get_status()
1615 for machine_id in status.machines:
1616 machine_status = status.machines[machine_id]
1617 if machine_status.agent_status.status == "pending":
1618 if only_manual and not machine_status.instance_id.startswith("manual:"):
1619 break
1620 machine = model.machines[machine_id]
1621 await machine.destroy(force=True)
1623 async def configure_application(
1624 self, model_name: str, application_name: str, config: dict = None
1625 ):
1626 """Configure application
1628 :param: model_name: Model name
1629 :param: application_name: Application name
1630 :param: config: Config to apply to the charm
1631 """
1632 self.log.debug("Configuring application {}".format(application_name))
1634 if config:
1635 controller = await self.get_controller()
1636 model = None
1637 try:
1638 model = await self.get_model(controller, model_name)
1639 application = self._get_application(
1640 model,
1641 application_name=application_name,
1642 )
1643 await application.set_config(config)
1644 finally:
1645 if model:
1646 await self.disconnect_model(model)
1647 await self.disconnect_controller(controller)
1649 async def health_check(self, interval: float = 300.0):
1650 """
1651 Health check to make sure controller and controller_model connections are OK
1653 :param: interval: Time in seconds between checks
1654 """
1655 controller = None
1656 while True:
1657 try:
1658 controller = await self.get_controller()
1659 # self.log.debug("VCA is alive")
1660 except Exception as e:
1661 self.log.error("Health check to VCA failed: {}".format(e))
1662 finally:
1663 await self.disconnect_controller(controller)
1664 await asyncio.sleep(interval)
1666 async def list_models(self, contains: str = None) -> [str]:
1667 """List models with certain names
1669 :param: contains: String that is contained in model name
1671 :retur: [models] Returns list of model names
1672 """
1674 controller = await self.get_controller()
1675 try:
1676 models = await controller.list_models()
1677 if contains:
1678 models = [model for model in models if contains in model]
1679 return models
1680 finally:
1681 await self.disconnect_controller(controller)
1683 async def _list_offers(
1684 self, model_name: str, offer_name: str = None
1685 ) -> QueryApplicationOffersResults:
1686 """
1687 List offers within a model
1689 :param: model_name: Model name
1690 :param: offer_name: Offer name to filter.
1692 :return: Returns application offers results in the model
1693 """
1695 controller = await self.get_controller()
1696 try:
1697 offers = (await controller.list_offers(model_name)).results
1698 if offer_name:
1699 matching_offer = []
1700 for offer in offers:
1701 if offer.offer_name == offer_name:
1702 matching_offer.append(offer)
1703 break
1704 offers = matching_offer
1705 return offers
1706 finally:
1707 await self.disconnect_controller(controller)
1709 async def add_k8s(
1710 self,
1711 name: str,
1712 rbac_id: str,
1713 token: str,
1714 client_cert_data: str,
1715 configuration: Configuration,
1716 storage_class: str,
1717 credential_name: str = None,
1718 ):
1719 """
1720 Add a Kubernetes cloud to the controller
1722 Similar to the `juju add-k8s` command in the CLI
1724 :param: name: Name for the K8s cloud
1725 :param: configuration: Kubernetes configuration object
1726 :param: storage_class: Storage Class to use in the cloud
1727 :param: credential_name: Storage Class to use in the cloud
1728 """
1730 if not storage_class:
1731 raise Exception("storage_class must be a non-empty string")
1732 if not name:
1733 raise Exception("name must be a non-empty string")
1734 if not configuration:
1735 raise Exception("configuration must be provided")
1737 endpoint = configuration.host
1738 credential = self.get_k8s_cloud_credential(
1739 configuration,
1740 client_cert_data,
1741 token,
1742 )
1743 credential.attrs[RBAC_LABEL_KEY_NAME] = rbac_id
1744 cloud = client.Cloud(
1745 type_="kubernetes",
1746 auth_types=[credential.auth_type],
1747 endpoint=endpoint,
1748 ca_certificates=[client_cert_data],
1749 config={
1750 "operator-storage": storage_class,
1751 "workload-storage": storage_class,
1752 },
1753 )
1755 return await self.add_cloud(
1756 name, cloud, credential, credential_name=credential_name
1757 )
1759 def get_k8s_cloud_credential(
1760 self,
1761 configuration: Configuration,
1762 client_cert_data: str,
1763 token: str = None,
1764 ) -> client.CloudCredential:
1765 attrs = {}
1766 # TODO: Test with AKS
1767 key = None # open(configuration.key_file, "r").read()
1768 username = configuration.username
1769 password = configuration.password
1771 if client_cert_data:
1772 attrs["ClientCertificateData"] = client_cert_data
1773 if key:
1774 attrs["ClientKeyData"] = key
1775 if token:
1776 if username or password:
1777 raise JujuInvalidK8sConfiguration("Cannot set both token and user/pass")
1778 attrs["Token"] = token
1780 auth_type = None
1781 if key:
1782 auth_type = "oauth2"
1783 if client_cert_data:
1784 auth_type = "oauth2withcert"
1785 if not token:
1786 raise JujuInvalidK8sConfiguration(
1787 "missing token for auth type {}".format(auth_type)
1788 )
1789 elif username:
1790 if not password:
1791 self.log.debug(
1792 "credential for user {} has empty password".format(username)
1793 )
1794 attrs["username"] = username
1795 attrs["password"] = password
1796 if client_cert_data:
1797 auth_type = "userpasswithcert"
1798 else:
1799 auth_type = "userpass"
1800 elif client_cert_data and token:
1801 auth_type = "certificate"
1802 else:
1803 raise JujuInvalidK8sConfiguration("authentication method not supported")
1804 return client.CloudCredential(auth_type=auth_type, attrs=attrs)
1806 async def add_cloud(
1807 self,
1808 name: str,
1809 cloud: Cloud,
1810 credential: CloudCredential = None,
1811 credential_name: str = None,
1812 ) -> Cloud:
1813 """
1814 Add cloud to the controller
1816 :param: name: Name of the cloud to be added
1817 :param: cloud: Cloud object
1818 :param: credential: CloudCredentials object for the cloud
1819 :param: credential_name: Credential name.
1820 If not defined, cloud of the name will be used.
1821 """
1822 controller = await self.get_controller()
1823 try:
1824 _ = await controller.add_cloud(name, cloud)
1825 if credential:
1826 await controller.add_credential(
1827 credential_name or name, credential=credential, cloud=name
1828 )
1829 # Need to return the object returned by the controller.add_cloud() function
1830 # I'm returning the original value now until this bug is fixed:
1831 # https://github.com/juju/python-libjuju/issues/443
1832 return cloud
1833 finally:
1834 await self.disconnect_controller(controller)
1836 async def remove_cloud(self, name: str):
1837 """
1838 Remove cloud
1840 :param: name: Name of the cloud to be removed
1841 """
1842 controller = await self.get_controller()
1843 try:
1844 await controller.remove_cloud(name)
1845 except juju.errors.JujuError as e:
1846 if len(e.errors) == 1 and f'cloud "{name}" not found' == e.errors[0]:
1847 self.log.warning(f"Cloud {name} not found, so it could not be deleted.")
1848 else:
1849 raise e
1850 finally:
1851 await self.disconnect_controller(controller)
1853 @retry(
1854 attempts=20, delay=5, fallback=JujuLeaderUnitNotFound(), callback=retry_callback
1855 )
1856 async def _get_leader_unit(self, application: Application) -> Unit:
1857 unit = None
1858 for u in application.units:
1859 if await u.is_leader_from_status():
1860 unit = u
1861 break
1862 if not unit:
1863 raise Exception()
1864 return unit
1866 async def get_cloud_credentials(self, cloud: Cloud) -> typing.List:
1867 """
1868 Get cloud credentials
1870 :param: cloud: Cloud object. The returned credentials will be from this cloud.
1872 :return: List of credentials object associated to the specified cloud
1874 """
1875 controller = await self.get_controller()
1876 try:
1877 facade = client.CloudFacade.from_connection(controller.connection())
1878 cloud_cred_tag = tag.credential(
1879 cloud.name, self.vca_connection.data.user, cloud.credential_name
1880 )
1881 params = [client.Entity(cloud_cred_tag)]
1882 return (await facade.Credential(params)).results
1883 finally:
1884 await self.disconnect_controller(controller)
1886 async def check_application_exists(self, model_name, application_name) -> bool:
1887 """Check application exists
1889 :param: model_name: Model Name
1890 :param: application_name: Application Name
1892 :return: Boolean
1893 """
1895 model = None
1896 controller = await self.get_controller()
1897 try:
1898 model = await self.get_model(controller, model_name)
1899 self.log.debug(
1900 "Checking if application {} exists in model {}".format(
1901 application_name, model_name
1902 )
1903 )
1904 return self._get_application(model, application_name) is not None
1905 finally:
1906 if model:
1907 await self.disconnect_model(model)
1908 await self.disconnect_controller(controller)