Coverage for n2vc/libjuju.py: 74%

766 statements  

« 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. 

14 

15import asyncio 

16import logging 

17import os 

18import typing 

19import yaml 

20 

21import time 

22 

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 

40 

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 

59 

60 

61RBAC_LABEL_KEY_NAME = "rbac-id" 

62 

63 

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 

70 

71 

72class Libjuju: 

73 def __init__( 

74 self, 

75 vca_connection: Connection, 

76 log: logging.Logger = None, 

77 n2vc: N2VCConnector = None, 

78 ): 

79 """ 

80 Constructor 

81 

82 :param: vca_connection: n2vc.vca.connection object 

83 :param: log: Logger 

84 :param: n2vc: N2VC object 

85 """ 

86 

87 self.log = log or logging.getLogger("Libjuju") 

88 self.n2vc = n2vc 

89 self.vca_connection = vca_connection 

90 

91 self.creating_model = asyncio.Lock() 

92 

93 if self.vca_connection.is_default: 

94 self.health_check_task = self._create_health_check_task() 

95 

96 def _create_health_check_task(self): 

97 return asyncio.get_event_loop().create_task(self.health_check()) 

98 

99 async def get_controller(self, timeout: float = 60.0) -> Controller: 

100 """ 

101 Get controller 

102 

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) 

134 

135 raise JujuControllerFailedConnecting( 

136 f"Error connecting to Juju controller: {e}" 

137 ) 

138 

139 async def disconnect(self): 

140 """Disconnect""" 

141 # Cancel health check task 

142 self.health_check_task.cancel() 

143 self.log.debug("Libjuju disconnected!") 

144 

145 async def disconnect_model(self, model: Model): 

146 """ 

147 Disconnect model 

148 

149 :param: model: Model that will be disconnected 

150 """ 

151 await model.disconnect() 

152 

153 async def disconnect_controller(self, controller: Controller): 

154 """ 

155 Disconnect controller 

156 

157 :param: controller: Controller that will be disconnected 

158 """ 

159 if controller: 

160 await controller.disconnect() 

161 

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 

166 

167 :param: model_name: Model name 

168 :param: cloud: Cloud object 

169 """ 

170 

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) 

178 

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) 

199 

200 async def get_executed_actions(self, model_name: str) -> list: 

201 """ 

202 Get executed/history of actions for a model. 

203 

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 

244 

245 async def get_application_configs( 

246 self, model_name: str, application_name: str 

247 ) -> dict: 

248 """ 

249 Get available configs for an application. 

250 

251 :param: model_name: Model name, str. 

252 :param: application_name: Application name, str. 

253 

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 

276 

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 

281 

282 :param: controller: Controller 

283 :param: model_name: Model name 

284 

285 :return: Model: The created Juju model object 

286 """ 

287 return await controller.get_model(model_name) 

288 

289 async def model_exists( 

290 self, model_name: str, controller: Controller = None 

291 ) -> bool: 

292 """ 

293 Check if model exists 

294 

295 :param: controller: Controller 

296 :param: model_name: Model name 

297 

298 :return bool 

299 """ 

300 need_to_disconnect = False 

301 

302 # Get controller if not passed 

303 if not controller: 

304 controller = await self.get_controller() 

305 need_to_disconnect = True 

306 

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) 

313 

314 async def models_exist(self, model_names: [str]) -> (bool, list): 

315 """ 

316 Check if models exists 

317 

318 :param: model_names: List of strings with model names 

319 

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)) 

332 

333 return ( 

334 len(non_existing_models) == 0, 

335 non_existing_models, 

336 ) 

337 

338 async def get_model_status(self, model_name: str) -> FullStatus: 

339 """ 

340 Get model status 

341 

342 :param: model_name: Model name 

343 

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) 

353 

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 

366 

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 

374 

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 

380 

381 self.log.debug( 

382 "Creating machine (id={}) in model: {}".format(machine_id, model_name) 

383 ) 

384 

385 # Get controller 

386 controller = await self.get_controller() 

387 

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 ) 

397 

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)) 

409 

410 if machine is None: 

411 self.log.debug("Creating a new machine in model {}".format(model_name)) 

412 

413 # Create machine 

414 machine = await model.add_machine( 

415 spec=None, constraints=None, disks=None, series=series 

416 ) 

417 new = True 

418 

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) 

438 

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 

445 

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 

458 

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 

466 

467 :return: (Entity): Machine id 

468 """ 

469 self.log.debug( 

470 "Provisioning machine. model: {}, hostname: {}, username: {}".format( 

471 model_name, hostname, username 

472 ) 

473 ) 

474 

475 # Get controller 

476 controller = await self.get_controller() 

477 

478 # Get model 

479 model = await self.get_model(controller, model_name) 

480 

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 ) 

489 

490 # Provision machine 

491 params = await provisioner.provision_machine() 

492 

493 params.jobs = ["JobHostUnits"] 

494 

495 self.log.debug("Adding machine to model") 

496 connection = model.connection() 

497 client_facade = client.ClientFacade.from_connection(connection) 

498 

499 results = await client_facade.AddMachines(params=[params]) 

500 error = results.machines[0].error 

501 

502 if error: 

503 msg = "Error adding machine: {}".format(error.message) 

504 self.log.error(msg=msg) 

505 raise ValueError(msg) 

506 

507 machine_id = results.machines[0].machine 

508 

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 ) 

519 

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) 

528 

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) 

533 

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) 

553 

554 self.log.debug( 

555 "Machine provisioned {} in model {}".format(machine_id, model_name) 

556 ) 

557 

558 return machine_id 

559 

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` 

570 

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) 

591 

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. 

597 

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 ) 

612 

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. 

616 

617 Resolve method gets all the information regarding the channel, 

618 track, revision, type, source. 

619 

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 

631 

632 def _get_apps_in_instantiation_params(self, instantiation_params: dict) -> list: 

633 """Extract applications key in instantiation params. 

634 

635 Returns: 

636 List with the names of the applications in instantiation params. 

637 

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))) 

647 

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. 

651 

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] 

660 

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) 

664 

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 ) 

676 

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 

687 

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 

694 

695 :return: None 

696 """ 

697 

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) 

703 

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) 

708 

709 self.log.debug( 

710 "Adding unit (machine {}) to application {} in model ~{}".format( 

711 machine_id, application_name, model_name 

712 ) 

713 ) 

714 

715 await application.add_unit(to=machine_id) 

716 

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) 

739 

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 

748 

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 

753 

754 :return: None 

755 """ 

756 

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) 

762 

763 if application is None: 

764 raise JujuApplicationNotFound( 

765 "Application not found: {} (model={})".format( 

766 application_name, model_name 

767 ) 

768 ) 

769 

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 ) 

777 

778 unit_name = unit.name 

779 

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) 

786 

787 self.log.debug( 

788 "Waiting for unit {} to be destroyed in application {} (model={})...".format( 

789 unit_name, application_name, model_name 

790 ) 

791 ) 

792 

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) 

815 

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 

830 

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 

841 

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)) 

850 

851 # Get controller 

852 controller = await self.get_controller() 

853 

854 # Get model 

855 model = await self.get_model(controller, model_name) 

856 

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) 

861 

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 ) 

871 

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) 

881 

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) 

910 

911 return application 

912 

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 

922 

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 

927 

928 :return: (str, str): (output and status) 

929 """ 

930 

931 self.log.debug( 

932 "Upgrading charm {} in model {} from path {}".format( 

933 application_name, model_name, path 

934 ) 

935 ) 

936 

937 await self.resolve_application( 

938 model_name=model_name, application_name=application_name 

939 ) 

940 

941 # Get controller 

942 controller = await self.get_controller() 

943 

944 # Get model 

945 model = await self.get_model(controller, model_name) 

946 

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 ) 

957 

958 await application.refresh(path=path) 

959 

960 self.log.debug( 

961 "Wait until charm upgrade is completed for application {} (model={})".format( 

962 application_name, model_name 

963 ) 

964 ) 

965 

966 await JujuModelWatcher.ensure_units_idle( 

967 model=model, application=application 

968 ) 

969 

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 ) 

980 

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) 

986 

987 self.log.debug( 

988 "Application {} is ready in model {}".format( 

989 application_name, model_name 

990 ) 

991 ) 

992 

993 finally: 

994 await self.disconnect_model(model) 

995 await self.disconnect_controller(controller) 

996 

997 return application 

998 

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) 

1002 

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 ) 

1012 

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 

1025 

1026 await asyncio.sleep(1) 

1027 

1028 finally: 

1029 await self.disconnect_model(model) 

1030 await self.disconnect_controller(controller) 

1031 

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 

1053 

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) 

1059 

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) 

1069 

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 """ 

1075 

1076 model = None 

1077 controller = await self.get_controller() 

1078 try: 

1079 model = await self.get_model(controller, model_name) 

1080 

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) 

1124 

1125 def _get_application_count(self, model: Model, application_name: str) -> int: 

1126 """Get number of units of the application 

1127 

1128 :param: model: Model object 

1129 :param: application_name: Application name 

1130 

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) 

1136 

1137 def _get_application(self, model: Model, application_name: str) -> Application: 

1138 """Get application 

1139 

1140 :param: model: Model object 

1141 :param: application_name: Application name 

1142 

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] 

1147 

1148 def _get_unit(self, application: Application, machine_id: str) -> Unit: 

1149 """Get unit 

1150 

1151 :param: application: Application object 

1152 :param: machine_id: Machine id 

1153 

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 

1162 

1163 def _get_machine_info( 

1164 self, 

1165 model, 

1166 machine_id: str, 

1167 ) -> (str, str): 

1168 """Get machine info 

1169 

1170 :param: model: Model object 

1171 :param: machine_id: Machine id 

1172 

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 

1181 

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 

1194 

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 

1202 

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() 

1210 

1211 # Get model 

1212 model = await self.get_model(controller, model_name) 

1213 

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 ) 

1247 

1248 actions = await application.get_actions() 

1249 

1250 if action_name not in actions: 

1251 raise JujuActionNotFound( 

1252 "Action {} not in available actions".format(action_name) 

1253 ) 

1254 

1255 action = await unit.run_action(action_name, **kwargs) 

1256 

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 ) 

1271 

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 ) 

1277 

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) 

1286 

1287 return output, status 

1288 

1289 async def get_actions(self, application_name: str, model_name: str) -> dict: 

1290 """Get list of actions 

1291 

1292 :param: application_name: Application name 

1293 :param: model_name: Model name 

1294 

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 ) 

1304 

1305 # Get controller 

1306 controller = await self.get_controller() 

1307 

1308 # Get model 

1309 model = await self.get_model(controller, model_name) 

1310 

1311 try: 

1312 # Get application 

1313 application = self._get_application( 

1314 model, 

1315 application_name=application_name, 

1316 ) 

1317 

1318 # Return list of actions 

1319 return await application.get_actions() 

1320 

1321 finally: 

1322 # Disconnect from model and controller 

1323 await self.disconnect_model(model) 

1324 await self.disconnect_controller(controller) 

1325 

1326 async def get_metrics(self, model_name: str, application_name: str) -> dict: 

1327 """Get the metrics collected by the VCA. 

1328 

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 

1345 

1346 async def add_relation( 

1347 self, 

1348 model_name: str, 

1349 endpoint_1: str, 

1350 endpoint_2: str, 

1351 ): 

1352 """Add relation 

1353 

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 """ 

1359 

1360 self.log.debug("Adding relation: {} -> {}".format(endpoint_1, endpoint_2)) 

1361 

1362 # Get controller 

1363 controller = await self.get_controller() 

1364 

1365 # Get model 

1366 model = await self.get_model(controller, model_name) 

1367 

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) 

1383 

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 ) 

1389 

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 ) 

1395 

1396 async def offer(self, endpoint: RelationEndpoint) -> Offer: 

1397 """ 

1398 Create an offer from a RelationEndpoint 

1399 

1400 :param: endpoint: Relation endpoint 

1401 

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) 

1423 

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". 

1432 

1433 :param: model_name: Model name 

1434 :param: offer: Offer object to consume 

1435 :param: provider_libjuju: Libjuju object of the provider endpoint 

1436 

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 

1440 

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) 

1462 

1463 async def destroy_model(self, model_name: str, total_timeout: float = 1800): 

1464 """ 

1465 Destroy model 

1466 

1467 :param: model_name: Model name 

1468 :param: total_timeout: Timeout 

1469 """ 

1470 

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 

1477 

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) 

1485 

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) 

1502 

1503 async def _destroy_model( 

1504 self, 

1505 model_name: str, 

1506 controller: Controller, 

1507 ): 

1508 """ 

1509 Destroy model from controller 

1510 

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}") 

1516 

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) 

1525 

1526 await asyncio.sleep(5) 

1527 self.log.info(f"Model {model_name} deleted gracefully") 

1528 

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") 

1537 

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 

1551 

1552 async def destroy_application( 

1553 self, model_name: str, application_name: str, total_timeout: float 

1554 ): 

1555 """ 

1556 Destroy application 

1557 

1558 :param: model_name: Model name 

1559 :param: application_name: Application name 

1560 :param: total_timeout: Timeout 

1561 """ 

1562 

1563 controller = await self.get_controller() 

1564 model = None 

1565 

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)) 

1578 

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) 

1605 

1606 async def _destroy_pending_machines(self, model: Model, only_manual: bool = False): 

1607 """ 

1608 Destroy pending machines in a given model 

1609 

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) 

1622 

1623 async def configure_application( 

1624 self, model_name: str, application_name: str, config: dict = None 

1625 ): 

1626 """Configure application 

1627 

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)) 

1633 

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) 

1648 

1649 async def health_check(self, interval: float = 300.0): 

1650 """ 

1651 Health check to make sure controller and controller_model connections are OK 

1652 

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) 

1665 

1666 async def list_models(self, contains: str = None) -> [str]: 

1667 """List models with certain names 

1668 

1669 :param: contains: String that is contained in model name 

1670 

1671 :retur: [models] Returns list of model names 

1672 """ 

1673 

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) 

1682 

1683 async def _list_offers( 

1684 self, model_name: str, offer_name: str = None 

1685 ) -> QueryApplicationOffersResults: 

1686 """ 

1687 List offers within a model 

1688 

1689 :param: model_name: Model name 

1690 :param: offer_name: Offer name to filter. 

1691 

1692 :return: Returns application offers results in the model 

1693 """ 

1694 

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) 

1708 

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 

1721 

1722 Similar to the `juju add-k8s` command in the CLI 

1723 

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 """ 

1729 

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") 

1736 

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 ) 

1754 

1755 return await self.add_cloud( 

1756 name, cloud, credential, credential_name=credential_name 

1757 ) 

1758 

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 

1770 

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 

1779 

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) 

1805 

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 

1815 

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) 

1835 

1836 async def remove_cloud(self, name: str): 

1837 """ 

1838 Remove cloud 

1839 

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) 

1852 

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 

1865 

1866 async def get_cloud_credentials(self, cloud: Cloud) -> typing.List: 

1867 """ 

1868 Get cloud credentials 

1869 

1870 :param: cloud: Cloud object. The returned credentials will be from this cloud. 

1871 

1872 :return: List of credentials object associated to the specified cloud 

1873 

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) 

1885 

1886 async def check_application_exists(self, model_name, application_name) -> bool: 

1887 """Check application exists 

1888 

1889 :param: model_name: Model Name 

1890 :param: application_name: Application Name 

1891 

1892 :return: Boolean 

1893 """ 

1894 

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)