github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/jujupy/client.py (about) 1 # This file is part of JujuPy, a library for driving the Juju CLI. 2 # Copyright 2013-2017 Canonical Ltd. 3 # 4 # This program is free software: you can redistribute it and/or modify it 5 # under the terms of the Lesser GNU General Public License version 3, as 6 # published by the Free Software Foundation. 7 # 8 # This program is distributed in the hope that it will be useful, but WITHOUT 9 # ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, 10 # SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the Lesser 11 # GNU General Public License for more details. 12 # 13 # You should have received a copy of the Lesser GNU General Public License 14 # along with this program. If not, see <http://www.gnu.org/licenses/>. 15 16 17 from __future__ import print_function 18 19 from collections import ( 20 defaultdict, 21 namedtuple, 22 ) 23 from contextlib import ( 24 contextmanager, 25 ) 26 from copy import deepcopy 27 import errno 28 from itertools import chain 29 import json 30 from locale import getpreferredencoding 31 import logging 32 import os 33 import re 34 import shutil 35 import subprocess 36 import sys 37 import time 38 import pexpect 39 import yaml 40 41 from jujupy.backend import ( 42 JujuBackend, 43 ) 44 from jujupy.configuration import ( 45 get_bootstrap_config_path, 46 get_juju_home, 47 get_selected_environment, 48 ) 49 from jujupy.exceptions import ( 50 AgentsNotStarted, 51 ApplicationsNotStarted, 52 AuthNotAccepted, 53 ControllersTimeout, 54 InvalidEndpoint, 55 NameNotAccepted, 56 NoProvider, 57 StatusNotMet, 58 StatusTimeout, 59 TypeNotAccepted, 60 VotingNotEnabled, 61 WorkloadsNotReady, 62 ) 63 from jujupy.status import ( 64 AGENTS_READY, 65 coalesce_agent_status, 66 Status, 67 ) 68 from jujupy.controller import ( 69 Controllers, 70 ) 71 from jujupy.utility import ( 72 _dns_name_for_machine, 73 JujuResourceTimeout, 74 pause, 75 qualified_model_name, 76 skip_on_missing_file, 77 split_address_port, 78 temp_yaml_file, 79 unqualified_model_name, 80 until_timeout, 81 ensure_dir, 82 ) 83 from jujupy.wait_condition import ( 84 CommandComplete, 85 NoopCondition, 86 WaitAgentsStarted, 87 WaitMachineNotPresent, 88 WaitVersion, 89 ) 90 91 92 __metaclass__ = type 93 94 95 WIN_JUJU_CMD = os.path.join('\\', 'Progra~2', 'Juju', 'juju.exe') 96 97 CONTROLLER = 'controller' 98 KILL_CONTROLLER = 'kill-controller' 99 SYSTEM = 'system' 100 101 KVM_MACHINE = 'kvm' 102 LXC_MACHINE = 'lxc' 103 LXD_MACHINE = 'lxd' 104 105 _DEFAULT_BUNDLE_TIMEOUT = 3600 106 107 log = logging.getLogger("jujupy") 108 109 110 def get_teardown_timeout(client): 111 """Return the timeout need by the client to teardown resources.""" 112 if client.env.provider == 'azure': 113 return 2700 114 elif client.env.provider == 'gce': 115 return 1200 116 else: 117 return 600 118 119 120 def parse_new_state_server_from_error(error): 121 err_str = str(error) 122 output = getattr(error, 'output', None) 123 if output is not None: 124 err_str += output 125 matches = re.findall(r'Attempting to connect to (.*):22', err_str) 126 if matches: 127 return matches[-1] 128 return None 129 130 131 Machine = namedtuple('Machine', ['machine_id', 'info']) 132 133 134 class JujuData: 135 """Represents a model in a JUJU_DATA directory for juju.""" 136 137 def __init__(self, environment, config=None, juju_home=None, 138 controller=None, cloud_name=None, bootstrap_to=None): 139 """Constructor. 140 141 This extends SimpleEnvironment's constructor. 142 143 :param environment: Name of the environment. 144 :param config: Dictionary with configuration options; default is None. 145 :param juju_home: Path to JUJU_DATA directory. If None (the default), 146 the home directory is autodetected. 147 :param controller: Controller instance-- this model's controller. 148 If not given or None, a new instance is created. 149 :param bootstrap_to: A placement directive to use when bootstrapping. 150 See Juju provider docs to examples of what Juju might expect. 151 """ 152 if juju_home is None: 153 juju_home = get_juju_home() 154 self.user_name = None 155 if controller is None: 156 controller = Controller(environment) 157 self.controller = controller 158 self.environment = environment 159 self._config = config 160 self.juju_home = juju_home 161 self.bootstrap_to = bootstrap_to 162 if self._config is not None: 163 try: 164 provider = self.provider 165 except NoProvider: 166 provider = None 167 self.kvm = (bool(self._config.get('container') == 'kvm')) 168 self.maas = bool(provider == 'maas') 169 self.joyent = bool(provider == 'joyent') 170 self.logging_config = self._config.get('logging-config') 171 else: 172 self.kvm = False 173 self.maas = False 174 self.joyent = False 175 self.logging_config = None 176 self.credentials = {} 177 self.clouds = {} 178 self._cloud_name = cloud_name 179 180 @property 181 def provider(self): 182 """Return the provider type for this environment. 183 184 See get_cloud to determine the specific cloud. 185 """ 186 try: 187 return self._config['type'] 188 except KeyError: 189 raise NoProvider('No provider specified.') 190 191 def clone(self, model_name=None): 192 config = deepcopy(self._config) 193 if model_name is None: 194 model_name = self.environment 195 else: 196 config['name'] = unqualified_model_name(model_name) 197 result = JujuData( 198 model_name, config, juju_home=self.juju_home, 199 controller=self.controller, 200 bootstrap_to=self.bootstrap_to) 201 result.kvm = self.kvm 202 result.maas = self.maas 203 result.joyent = self.joyent 204 result.user_name = self.user_name 205 result.credentials = deepcopy(self.credentials) 206 result.clouds = deepcopy(self.clouds) 207 result._cloud_name = self._cloud_name 208 result.logging_config = self.logging_config 209 return result 210 211 @classmethod 212 def from_env(cls, env): 213 juju_data = cls(env.environment, env._config, env.juju_home) 214 juju_data.load_yaml() 215 return juju_data 216 217 def make_config_copy(self): 218 return deepcopy(self._config) 219 220 @contextmanager 221 def make_juju_home(self, juju_home, dir_name): 222 """Make a JUJU_HOME/DATA directory to avoid conflicts. 223 224 :param juju_home: Current JUJU_HOME/DATA directory, used as a 225 base path for the new directory. 226 :param dir_name: Name of sub-directory to make the home in. 227 """ 228 home_path = juju_home_path(juju_home, dir_name) 229 with skip_on_missing_file(): 230 shutil.rmtree(home_path) 231 os.makedirs(home_path) 232 self.dump_yaml(home_path) 233 yield home_path 234 235 def update_config(self, new_config): 236 if 'type' in new_config: 237 raise ValueError('type cannot be set via update_config.') 238 if self._cloud_name is not None: 239 # Do not accept changes that would alter the computed cloud name 240 # if computed cloud names are not in use. 241 for endpoint_key in ['maas-server', 'auth-url', 'host']: 242 if endpoint_key in new_config: 243 raise ValueError( 244 '{} cannot be changed with explicit cloud' 245 ' name.'.format(endpoint_key)) 246 247 for key, value in new_config.items(): 248 if key == 'region': 249 logging.warning( 250 'Using set_region to set region to "{}".'.format(value)) 251 self.set_region(value) 252 continue 253 if key == 'type': 254 logging.warning('Setting type is not 2.x compatible.') 255 self._config[key] = value 256 257 def load_yaml(self): 258 try: 259 with open(os.path.join(self.juju_home, 'credentials.yaml')) as f: 260 self.credentials = yaml.safe_load(f) 261 except IOError as e: 262 if e.errno != errno.ENOENT: 263 raise RuntimeError( 264 'Failed to read credentials file: {}'.format(str(e))) 265 self.credentials = {} 266 self.clouds = self.read_clouds() 267 268 def read_clouds(self): 269 """Read and return clouds.yaml as a Python dict.""" 270 try: 271 with open(os.path.join(self.juju_home, 'clouds.yaml')) as f: 272 return yaml.safe_load(f) 273 except IOError as e: 274 if e.errno != errno.ENOENT: 275 raise RuntimeError( 276 'Failed to read clouds file: {}'.format(str(e))) 277 # Default to an empty clouds file. 278 return {'clouds': {}} 279 280 @classmethod 281 def from_config(cls, name): 282 """Create a model from the three configuration files.""" 283 juju_data = cls._from_config(name) 284 juju_data.load_yaml() 285 return juju_data 286 287 @classmethod 288 def _from_config(cls, name): 289 config, selected = get_selected_environment(name) 290 if name is None: 291 name = selected 292 return cls(name, config) 293 294 @classmethod 295 def from_cloud_region(cls, cloud, region, config, clouds, juju_home): 296 """Return a JujuData for the specified cloud and region. 297 298 :param cloud: The name of the cloud to use. 299 :param region: The name of the region to use. If None, an arbitrary 300 region will be selected. 301 :param config: The bootstrap config to use. 302 :param juju_home: The JUJU_DATA directory to use (credentials are 303 loaded from this.) 304 """ 305 cloud_config = clouds['clouds'][cloud] 306 provider = cloud_config['type'] 307 config['type'] = provider 308 if provider == 'maas': 309 config['maas-server'] = cloud_config['endpoint'] 310 elif provider == 'openstack': 311 config['auth-url'] = cloud_config['endpoint'] 312 elif provider == 'vsphere': 313 config['host'] = cloud_config['endpoint'] 314 data = JujuData(cloud, config, juju_home, cloud_name=cloud) 315 data.load_yaml() 316 data.clouds = clouds 317 if region is None: 318 regions = cloud_config.get('regions', {}).keys() 319 if len(regions) > 0: 320 region = regions[0] 321 data.set_region(region) 322 return data 323 324 @classmethod 325 def for_existing(cls, juju_data_dir, controller_name, model_name): 326 with open(get_bootstrap_config_path(juju_data_dir)) as f: 327 all_bootstrap = yaml.load(f) 328 ctrl_config = all_bootstrap['controllers'][controller_name] 329 config = ctrl_config['controller-config'] 330 # config is expected to have a 1.x style of config, so mash up 331 # controller and model config. 332 config.update(ctrl_config['model-config']) 333 config['type'] = ctrl_config['type'] 334 data = cls( 335 model_name, config, juju_data_dir, Controller(controller_name), 336 ctrl_config['cloud'] 337 ) 338 data.set_region(ctrl_config['region']) 339 data.load_yaml() 340 return data 341 342 def dump_yaml(self, path): 343 """Dump the configuration files to the specified path.""" 344 with open(os.path.join(path, 'credentials.yaml'), 'w') as f: 345 yaml.safe_dump(self.credentials, f) 346 self.write_clouds(path, self.clouds) 347 348 @staticmethod 349 def write_clouds(path, clouds): 350 with open(os.path.join(path, 'clouds.yaml'), 'w') as f: 351 yaml.safe_dump(clouds, f) 352 353 def find_endpoint_cloud(self, cloud_type, endpoint): 354 for cloud, cloud_config in self.clouds['clouds'].items(): 355 if cloud_config['type'] != cloud_type: 356 continue 357 if cloud_config['endpoint'] == endpoint: 358 return cloud 359 raise LookupError('No such endpoint: {}'.format(endpoint)) 360 361 def set_model_name(self, model_name, set_controller=True): 362 if set_controller: 363 self.controller.name = model_name 364 self.environment = model_name 365 self._config['name'] = unqualified_model_name(model_name) 366 367 def set_region(self, region): 368 """Assign the region to a 1.x-style config. 369 370 This requires translating Azure's and Joyent's conventions for 371 specifying region. 372 373 It means that endpoint, rather than region, should be updated if the 374 cloud (not the provider) is named "lxd" or "manual". 375 376 Only None is acccepted for MAAS. 377 """ 378 try: 379 provider = self.provider 380 cloud_is_provider = self.is_cloud_provider() 381 except NoProvider: 382 provider = None 383 cloud_is_provider = False 384 if provider == 'azure': 385 self._config['location'] = region 386 elif provider == 'joyent': 387 self._config['sdc-url'] = ( 388 'https://{}.api.joyentcloud.com'.format(region)) 389 elif cloud_is_provider: 390 self._set_config_endpoint(region) 391 elif provider == 'maas': 392 if region is not None: 393 raise ValueError('Only None allowed for maas.') 394 else: 395 self._config['region'] = region 396 397 def get_cloud(self): 398 if self._cloud_name is not None: 399 return self._cloud_name 400 provider = self.provider 401 # Separate cloud recommended by: Juju Cloud / Credentials / BootStrap / 402 # Model CLI specification 403 if provider == 'ec2' and self._config['region'] == 'cn-north-1': 404 return 'aws-china' 405 if provider not in ('maas', 'openstack', 'vsphere'): 406 return { 407 'ec2': 'aws', 408 'gce': 'google', 409 }.get(provider, provider) 410 if provider == 'maas': 411 endpoint = self._config['maas-server'] 412 elif provider == 'openstack': 413 endpoint = self._config['auth-url'] 414 elif provider == 'vsphere': 415 endpoint = self._config['host'] 416 return self.find_endpoint_cloud(provider, endpoint) 417 418 def get_cloud_credentials_item(self): 419 cloud_name = self.get_cloud() 420 cloud = self.credentials['credentials'][cloud_name] 421 # cloud credential info may include defaults we need to remove 422 cloud_cred = {k: v for k, v in cloud.iteritems() if k not in ['default-region', 'default-credential']} 423 (credentials_item,) = cloud_cred.items() 424 return credentials_item 425 426 def get_cloud_credentials(self): 427 """Return the credentials for this model's cloud.""" 428 return self.get_cloud_credentials_item()[1] 429 430 def get_option(self, key, default=None): 431 return self._config.get(key, default) 432 433 def discard_option(self, key): 434 return self._config.pop(key, None) 435 436 def get_region(self): 437 """Determine the region from a 1.x-style config. 438 439 This requires translating Azure's and Joyent's conventions for 440 specifying region. 441 442 It means that endpoint, rather than region, should be supplied if the 443 cloud (not the provider) is named "lxd" or "manual". 444 445 May return None for MAAS or LXD clouds. 446 """ 447 provider = self.provider 448 # In 1.x, providers define region differently. Translate. 449 if provider == 'azure': 450 if 'tenant-id' not in self._config: 451 return self._config['location'].replace(' ', '').lower() 452 return self._config['location'] 453 elif provider == 'joyent': 454 matcher = re.compile('https://(.*).api.joyentcloud.com') 455 return matcher.match(self._config['sdc-url']).group(1) 456 elif provider == 'maas': 457 return None 458 # In 2.x, certain providers can be specified on the commandline in 459 # place of a cloud. The "region" in these cases is the endpoint. 460 elif self.is_cloud_provider(): 461 return self._get_config_endpoint() 462 else: 463 # The manual provider is typically used without a region. 464 if provider == 'manual': 465 return self._config.get('region') 466 return self._config['region'] 467 468 def is_cloud_provider(self): 469 """Return True if the commandline cloud is a provider. 470 471 Examples: lxd, manual 472 """ 473 # if the commandline cloud is "lxd" or "manual", the provider type 474 # should match, and shortcutting get_cloud avoids pointless test 475 # breakage. 476 return bool(self.provider in ('lxd', 'manual') and 477 self.get_cloud() in ('lxd', 'manual')) 478 479 def _get_config_endpoint(self): 480 if self.provider == 'lxd': 481 return self._config.get('region', 'localhost') 482 elif self.provider == 'manual': 483 return self._config['bootstrap-host'] 484 485 def _set_config_endpoint(self, endpoint): 486 if self.provider == 'lxd': 487 self._config['region'] = endpoint 488 elif self.provider == 'manual': 489 self._config['bootstrap-host'] = endpoint 490 491 def __eq__(self, other): 492 if type(self) != type(other): 493 return False 494 if self.environment != other.environment: 495 return False 496 if self._config != other._config: 497 return False 498 if self.maas != other.maas: 499 return False 500 if self.bootstrap_to != other.bootstrap_to: 501 return False 502 return True 503 504 def __ne__(self, other): 505 return not self == other 506 507 508 def describe_substrate(env): 509 if env.provider == 'openstack': 510 if env.get_option('auth-url') == ( 511 'https://keystone.canonistack.canonical.com:443/v2.0/'): 512 return 'Canonistack' 513 else: 514 return 'Openstack' 515 try: 516 return { 517 'ec2': 'AWS', 518 'rackspace': 'Rackspace', 519 'joyent': 'Joyent', 520 'azure': 'Azure', 521 'maas': 'MAAS', 522 }[env.provider] 523 except KeyError: 524 return env.provider 525 526 527 def get_stripped_version_number(version_string): 528 return get_version_string_parts(version_string)[0] 529 530 531 def get_version_string_parts(version_string): 532 # strip the series and arch from the built version. 533 version_parts = version_string.split('-') 534 if len(version_parts) == 4: 535 # Version contains "-<patchname>", reconstruct it after the split. 536 return '-'.join(version_parts[0:2]), version_parts[2], version_parts[3] 537 else: 538 try: 539 return version_parts[0], version_parts[1], version_parts[2] 540 except IndexError: 541 # Possible version_string was only version (i.e. 2.0.0), 542 # namely tests. 543 return version_parts 544 545 546 class ModelClient: 547 """Wraps calls to a juju instance, associated with a single model. 548 549 Note: A model is often called an environment (Juju 1 legacy). 550 551 This class represents the latest Juju version. 552 """ 553 554 # The environments.yaml options that are replaced by bootstrap options. 555 # 556 # As described in bug #1538735, default-series and --bootstrap-series must 557 # match. 'default-series' should be here, but is omitted so that 558 # default-series is always forced to match --bootstrap-series. 559 bootstrap_replaces = frozenset(['agent-version']) 560 561 # What feature flags have existed that CI used. 562 known_feature_flags = frozenset(['actions', 'migration']) 563 564 # What feature flags are used by this version of the juju client. 565 used_feature_flags = frozenset(['migration']) 566 567 destroy_model_command = 'destroy-model' 568 569 supported_container_types = frozenset([KVM_MACHINE, LXC_MACHINE, 570 LXD_MACHINE]) 571 572 default_backend = JujuBackend 573 574 config_class = JujuData 575 576 status_class = Status 577 578 controllers_class = Controllers 579 580 agent_metadata_url = 'agent-metadata-url' 581 582 model_permissions = frozenset(['read', 'write', 'admin']) 583 584 controller_permissions = frozenset(['login', 'add-model', 'superuser']) 585 586 # Granting 'login' will error as a created user has that at creation. 587 ignore_permissions = frozenset(['login']) 588 589 reserved_spaces = frozenset([ 590 'endpoint-bindings-data', 'endpoint-bindings-public']) 591 592 command_set_destroy_model = 'destroy-model' 593 594 command_set_remove_object = 'remove-object' 595 596 command_set_all = 'all' 597 598 REGION_ENDPOINT_PROMPT = ( 599 r'Enter the API endpoint url for the region \[use cloud api url\]:') 600 601 login_user_command = 'login -u' 602 603 @classmethod 604 def preferred_container(cls): 605 for container_type in [LXD_MACHINE, LXC_MACHINE]: 606 if container_type in cls.supported_container_types: 607 return container_type 608 609 _show_status = 'show-status' 610 _show_controller = 'show-controller' 611 612 @classmethod 613 def get_version(cls, juju_path=None): 614 """Get the version data from a juju binary. 615 616 :param juju_path: Path to binary. If not given or None, 'juju' is used. 617 """ 618 if juju_path is None: 619 juju_path = 'juju' 620 version = subprocess.check_output((juju_path, '--version')).strip() 621 return version.decode("utf-8") 622 623 def check_timeouts(self): 624 return self._backend._check_timeouts() 625 626 def ignore_soft_deadline(self): 627 return self._backend.ignore_soft_deadline() 628 629 def enable_feature(self, flag): 630 """Enable juju feature by setting the given flag. 631 632 New versions of juju with the feature enabled by default will silently 633 allow this call, but will not export the environment variable. 634 """ 635 if flag not in self.known_feature_flags: 636 raise ValueError('Unknown feature flag: %r' % (flag,)) 637 self.feature_flags.add(flag) 638 639 @classmethod 640 def get_full_path(cls): 641 if sys.platform == 'win32': 642 return WIN_JUJU_CMD 643 return subprocess.check_output( 644 ('which', 'juju')).decode(getpreferredencoding()).rstrip('\n') 645 646 def clone_from_path(self, juju_path): 647 """Clone using the supplied path.""" 648 if juju_path is None: 649 full_path = self.get_full_path() 650 else: 651 full_path = os.path.abspath(juju_path) 652 return self.clone( 653 full_path=full_path, version=self.get_version(juju_path)) 654 655 def clone(self, env=None, version=None, full_path=None, debug=None, 656 cls=None): 657 """Create a clone of this ModelClient. 658 659 By default, the class, environment, version, full_path, and debug 660 settings will match the original, but each can be overridden. 661 """ 662 if env is None: 663 env = self.env 664 if cls is None: 665 cls = self.__class__ 666 feature_flags = self.feature_flags.intersection(cls.used_feature_flags) 667 backend = self._backend.clone(full_path, version, debug, feature_flags) 668 other = cls.from_backend(backend, env) 669 other.excluded_spaces = set(self.excluded_spaces) 670 return other 671 672 @classmethod 673 def from_backend(cls, backend, env): 674 return cls(env=env, version=backend.version, 675 full_path=backend.full_path, 676 debug=backend.debug, _backend=backend) 677 678 def get_cache_path(self): 679 return get_cache_path(self.env.juju_home, models=True) 680 681 def _cmd_model(self, include_e, controller): 682 if controller: 683 return '{controller}:{model}'.format( 684 controller=self.env.controller.name, 685 model=self.get_controller_model_name()) 686 elif self.env is None or not include_e: 687 return None 688 else: 689 return '{controller}:{model}'.format( 690 controller=self.env.controller.name, 691 model=self.model_name) 692 693 def __init__(self, env, version, full_path, juju_home=None, debug=False, 694 soft_deadline=None, _backend=None): 695 """Create a new juju client. 696 697 Required Arguments 698 :param env: JujuData object representing a model in a data directory. 699 :param version: Version of juju the client wraps. 700 :param full_path: Full path to juju binary. 701 702 Optional Arguments 703 :param juju_home: default value for env.juju_home. Will be 704 autodetected if None (the default). 705 :param debug: Flag to activate debugging output; False by default. 706 :param soft_deadline: A datetime representing the deadline by which 707 normal operations should complete. If None, no deadline is 708 enforced. 709 :param _backend: The backend to use for interacting with the client. 710 If None (the default), self.default_backend will be used. 711 """ 712 self.env = env 713 if _backend is None: 714 _backend = self.default_backend(full_path, version, set(), debug, 715 soft_deadline) 716 self._backend = _backend 717 if version != _backend.version: 718 raise ValueError('Version mismatch: {} {}'.format( 719 version, _backend.version)) 720 if full_path != _backend.full_path: 721 raise ValueError('Path mismatch: {} {}'.format( 722 full_path, _backend.full_path)) 723 if debug is not _backend.debug: 724 raise ValueError('debug mismatch: {} {}'.format( 725 debug, _backend.debug)) 726 if env is not None: 727 if juju_home is None: 728 if env.juju_home is None: 729 env.juju_home = get_juju_home() 730 else: 731 env.juju_home = juju_home 732 self.excluded_spaces = set(self.reserved_spaces) 733 734 @property 735 def version(self): 736 return self._backend.version 737 738 @property 739 def full_path(self): 740 return self._backend.full_path 741 742 @property 743 def feature_flags(self): 744 return self._backend.feature_flags 745 746 @feature_flags.setter 747 def feature_flags(self, feature_flags): 748 self._backend.feature_flags = feature_flags 749 750 @property 751 def debug(self): 752 return self._backend.debug 753 754 @property 755 def model_name(self): 756 return self.env.environment 757 758 def _shell_environ(self): 759 """Generate a suitable shell environment. 760 761 Juju's directory must be in the PATH to support plugins. 762 """ 763 return self._backend.shell_environ(self.used_feature_flags, 764 self.env.juju_home) 765 766 def use_reserved_spaces(self, spaces): 767 """Allow machines in given spaces to be allocated and used.""" 768 if not self.reserved_spaces.issuperset(spaces): 769 raise ValueError('Space not reserved: {}'.format(spaces)) 770 self.excluded_spaces.difference_update(spaces) 771 772 def add_ssh_machines(self, machines): 773 for count, machine in enumerate(machines): 774 try: 775 self.juju('add-machine', ('ssh:' + machine,)) 776 except subprocess.CalledProcessError: 777 if count != 0: 778 raise 779 logging.warning('add-machine failed. Will retry.') 780 pause(30) 781 self.juju('add-machine', ('ssh:' + machine,)) 782 783 def make_remove_machine_condition(self, machine): 784 """Return a condition object representing a machine removal. 785 786 The timeout varies depending on the provider. 787 See wait_for. 788 """ 789 if self.env.provider == 'azure': 790 timeout = 1200 791 else: 792 timeout = 600 793 return WaitMachineNotPresent(machine, timeout) 794 795 def remove_machine(self, machine_id, force=False): 796 """Remove a machine (or container). 797 798 :param machine_id: The id of the machine to remove. 799 :return: A WaitMachineNotPresent instance for client.wait_for. 800 """ 801 if force: 802 options = ('--force',) 803 else: 804 options = () 805 self.juju('remove-machine', options + (machine_id,)) 806 return self.make_remove_machine_condition(machine_id) 807 808 @staticmethod 809 def get_cloud_region(cloud, region): 810 if region is None: 811 return cloud 812 return '{}/{}'.format(cloud, region) 813 814 def get_bootstrap_args( 815 self, upload_tools, config_filename, bootstrap_series=None, 816 credential=None, auto_upgrade=False, metadata_source=None, 817 no_gui=False, agent_version=None): 818 """Return the bootstrap arguments for the substrate.""" 819 constraints = self._get_substrate_constraints() 820 cloud_region = self.get_cloud_region(self.env.get_cloud(), 821 self.env.get_region()) 822 # Note cloud_region before controller name 823 args = ['--constraints', constraints, 824 cloud_region, 825 self.env.environment, 826 '--config', config_filename, 827 '--default-model', self.env.environment] 828 if upload_tools: 829 if agent_version is not None: 830 raise ValueError( 831 'agent-version may not be given with upload-tools.') 832 args.insert(0, '--upload-tools') 833 else: 834 if agent_version is None: 835 agent_version = self.get_matching_agent_version() 836 args.extend(['--agent-version', agent_version]) 837 if bootstrap_series is not None: 838 args.extend(['--bootstrap-series', bootstrap_series]) 839 if credential is not None: 840 args.extend(['--credential', credential]) 841 if metadata_source is not None: 842 args.extend(['--metadata-source', metadata_source]) 843 if auto_upgrade: 844 args.append('--auto-upgrade') 845 if self.env.bootstrap_to is not None: 846 args.extend(['--to', self.env.bootstrap_to]) 847 if no_gui: 848 args.append('--no-gui') 849 return tuple(args) 850 851 def add_model(self, env, cloud_region=None): 852 """Add a model to this model's controller and return its client. 853 854 :param env: Either a class representing the new model/environment 855 or the name of the new model/environment which will then be 856 otherwise identical to the current model/environment.""" 857 if not isinstance(env, JujuData): 858 env = self.env.clone(env) 859 model_client = self.clone(env) 860 with model_client._bootstrap_config() as config_file: 861 self._add_model(env.environment, config_file, cloud_region=cloud_region) 862 # Make sure we track this in case it needs special cleanup (i.e. using 863 # an existing controller). 864 self._backend.track_model(model_client) 865 return model_client 866 867 def make_model_config(self): 868 config_dict = make_safe_config(self) 869 agent_metadata_url = config_dict.pop('tools-metadata-url', None) 870 if agent_metadata_url is not None: 871 config_dict.setdefault('agent-metadata-url', agent_metadata_url) 872 # Strip unneeded variables. 873 return dict((k, v) for k, v in config_dict.items() if k not in { 874 'access-key', 875 'api-port', 876 'admin-secret', 877 'application-id', 878 'application-password', 879 'audit-log-capture-args', 880 'audit-log-max-size', 881 'audit-log-max-backups', 882 'auditing-enabled', 883 'audit-log-exclude-methods', 884 'auth-url', 885 'bootstrap-host', 886 'client-email', 887 'client-id', 888 'control-bucket', 889 'host', 890 'location', 891 'maas-oauth', 892 'maas-server', 893 'management-certificate', 894 'management-subscription-id', 895 'manta-key-id', 896 'manta-user', 897 'max-logs-age', 898 'max-logs-size', 899 'max-txn-log-size', 900 'name', 901 'password', 902 'private-key', 903 'region', 904 'sdc-key-id', 905 'sdc-url', 906 'sdc-user', 907 'secret-key', 908 'set-numa-control-policy', 909 'state-port', 910 'storage-account-name', 911 'subscription-id', 912 'tenant-id', 913 'tenant-name', 914 'type', 915 'username', 916 }) 917 918 @contextmanager 919 def _bootstrap_config(self): 920 with temp_yaml_file(self.make_model_config()) as config_filename: 921 yield config_filename 922 923 def _check_bootstrap(self): 924 if self.env.environment != self.env.controller.name: 925 raise AssertionError( 926 'Controller and environment names should not vary (yet)') 927 928 def update_user_name(self): 929 self.env.user_name = 'admin' 930 931 def bootstrap(self, upload_tools=False, bootstrap_series=None, 932 credential=None, auto_upgrade=False, metadata_source=None, 933 no_gui=False, agent_version=None): 934 """Bootstrap a controller.""" 935 self._check_bootstrap() 936 with self._bootstrap_config() as config_filename: 937 args = self.get_bootstrap_args( 938 upload_tools, config_filename, bootstrap_series, credential, 939 auto_upgrade, metadata_source, no_gui, agent_version) 940 self.update_user_name() 941 retvar, ct = self.juju('bootstrap', args, include_e=False) 942 ct.actual_completion() 943 return retvar 944 945 @contextmanager 946 def bootstrap_async(self, upload_tools=False, bootstrap_series=None, 947 auto_upgrade=False, metadata_source=None, 948 no_gui=False): 949 self._check_bootstrap() 950 with self._bootstrap_config() as config_filename: 951 args = self.get_bootstrap_args( 952 upload_tools, config_filename, bootstrap_series, None, 953 auto_upgrade, metadata_source, no_gui) 954 self.update_user_name() 955 with self.juju_async('bootstrap', args, include_e=False): 956 yield 957 log.info('Waiting for bootstrap of {}.'.format( 958 self.env.environment)) 959 960 def _add_model(self, model_name, config_file, cloud_region=None): 961 explicit_region = self.env.controller.explicit_region 962 region_args = (cloud_region, ) if cloud_region else () 963 if explicit_region and not region_args: 964 credential_name = self.env.get_cloud_credentials_item()[0] 965 cloud_region = self.get_cloud_region(self.env.get_cloud(), 966 self.env.get_region()) 967 region_args = (cloud_region, '--credential', credential_name) 968 self.controller_juju('add-model', (model_name,) + region_args + 969 ('--config', config_file,)) 970 971 def destroy_model(self): 972 exit_status, _ = self.juju( 973 'destroy-model', 974 ('{}:{}'.format(self.env.controller.name, self.env.environment), 975 '-y', '--destroy-storage',), 976 include_e=False, timeout=get_teardown_timeout(self)) 977 # Ensure things don't get confused at teardown time (i.e. if using an 978 # existing controller) 979 self._backend.untrack_model(self) 980 return exit_status 981 982 def kill_controller(self, check=False): 983 """Kill a controller and its models. Hard kill option. 984 985 :return: Tuple: Subprocess's exit code, CommandComplete object. 986 """ 987 retvar, ct = self.juju( 988 'kill-controller', (self.env.controller.name, '-y'), 989 include_e=False, check=check, timeout=get_teardown_timeout(self)) 990 # Already satisfied as this is a sync, operation. 991 ct.actual_completion() 992 return retvar 993 994 def destroy_controller(self, all_models=False, destroy_storage=False, release_storage=False): 995 """Destroy a controller and its models. Soft kill option. 996 997 :param all_models: If true will attempt to destroy all the 998 controller's models as well. 999 :raises: subprocess.CalledProcessError if the operation fails. 1000 :return: Tuple: Subprocess's exit code, CommandComplete object. 1001 """ 1002 args = (self.env.controller.name, '-y') 1003 if all_models: 1004 args += ('--destroy-all-models',) 1005 if destroy_storage: 1006 args += ('--destroy-storage',) 1007 if release_storage: 1008 args += ('--release-storage',) 1009 retvar, ct = self.juju( 1010 'destroy-controller', args, include_e=False, 1011 timeout=get_teardown_timeout(self)) 1012 # Already satisfied as this is a sync, operation. 1013 ct.actual_completion() 1014 return retvar 1015 1016 def tear_down(self): 1017 """Tear down the client as cleanly as possible. 1018 1019 Attempts to use the soft method destroy_controller, if that fails 1020 it will use the hard kill_controller and raise an error.""" 1021 try: 1022 self.destroy_controller(all_models=True, destroy_storage=True) 1023 except subprocess.CalledProcessError: 1024 logging.warning('tear_down destroy-controller failed') 1025 retval = self.kill_controller() 1026 message = 'tear_down kill-controller result={}'.format(retval) 1027 if retval == 0: 1028 logging.info(message) 1029 else: 1030 logging.warning(message) 1031 raise 1032 1033 def get_juju_output(self, command, *args, **kwargs): 1034 """Call a juju command and return the output. 1035 1036 Sub process will be called as 'juju <command> <args> <kwargs>'. Note 1037 that <command> may be a space delimited list of arguments. The -e 1038 <environment> flag will be placed after <command> and before args. 1039 """ 1040 model = self._cmd_model(kwargs.get('include_e', True), 1041 kwargs.get('controller', False)) 1042 pass_kwargs = dict( 1043 (k, kwargs[k]) for k in kwargs if k in ['timeout', 'merge_stderr']) 1044 return self._backend.get_juju_output( 1045 command, args, self.used_feature_flags, self.env.juju_home, 1046 model, user_name=self.env.user_name, **pass_kwargs) 1047 1048 def show_status(self): 1049 """Print the status to output.""" 1050 self.juju(self._show_status, ('--format', 'yaml')) 1051 1052 def get_status(self, timeout=60, raw=False, controller=False, *args): 1053 """Get the current status as a jujupy.status.Status object.""" 1054 # GZ 2015-12-16: Pass remaining timeout into get_juju_output call. 1055 for ignored in until_timeout(timeout): 1056 try: 1057 if raw: 1058 return self.get_juju_output(self._show_status, *args) 1059 return self.status_class.from_text( 1060 self.get_juju_output( 1061 self._show_status, '--format', 'yaml', 1062 controller=controller).decode('utf-8')) 1063 except subprocess.CalledProcessError: 1064 pass 1065 raise StatusTimeout( 1066 'Timed out waiting for juju status to succeed') 1067 1068 def get_controllers(self, timeout=60): 1069 """Get the current controller information as a dict.""" 1070 for ignored in until_timeout(timeout): 1071 try: 1072 return self.controllers_class.from_text( 1073 self.get_juju_output( 1074 self._show_controller, '--format', 'yaml', 1075 include_e=False, 1076 ).decode('utf-8'), 1077 ) 1078 except subprocess.CalledProcessError: 1079 pass 1080 raise ControllersTimeout( 1081 'Timed out waiting for juju show-controllers to succeed') 1082 1083 def show_model(self, model_name=None): 1084 model_details = self.get_juju_output( 1085 'show-model', 1086 '{}:{}'.format( 1087 self.env.controller.name, model_name or self.env.environment), 1088 '--format', 'yaml', 1089 include_e=False) 1090 return yaml.safe_load(model_details) 1091 1092 @staticmethod 1093 def _dict_as_option_strings(options): 1094 return tuple('{}={}'.format(*item) for item in options.items()) 1095 1096 def set_config(self, service, options): 1097 option_strings = self._dict_as_option_strings(options) 1098 self.juju('config', (service,) + option_strings) 1099 1100 def get_config(self, service): 1101 return yaml.safe_load(self.get_juju_output('config', service)) 1102 1103 def get_service_config(self, service, timeout=60): 1104 for ignored in until_timeout(timeout): 1105 try: 1106 return self.get_config(service) 1107 except subprocess.CalledProcessError: 1108 pass 1109 raise Exception( 1110 'Timed out waiting for juju get %s' % (service)) 1111 1112 def set_model_constraints(self, constraints): 1113 constraint_strings = self._dict_as_option_strings(constraints) 1114 retvar, ct = self.juju('set-model-constraints', constraint_strings) 1115 return retvar, CommandComplete(NoopCondition(), ct) 1116 1117 def get_model_config(self): 1118 """Return the value of the environment's configured options.""" 1119 return yaml.safe_load( 1120 self.get_juju_output('model-config', '--format', 'yaml')) 1121 1122 def get_env_option(self, option): 1123 """Return the value of the environment's configured option.""" 1124 return self.get_juju_output( 1125 'model-config', option).decode(getpreferredencoding()) 1126 1127 def set_env_option(self, option, value): 1128 """Set the value of the option in the environment.""" 1129 option_value = "%s=%s" % (option, value) 1130 retvar, ct = self.juju('model-config', (option_value,)) 1131 return CommandComplete(NoopCondition(), ct) 1132 1133 def unset_env_option(self, option): 1134 """Unset the value of the option in the environment.""" 1135 retvar, ct = self.juju('model-config', ('--reset', option,)) 1136 return CommandComplete(NoopCondition(), ct) 1137 1138 @staticmethod 1139 def _format_cloud_region(cloud=None, region=None): 1140 """Return the [[cloud/]region] in a tupple.""" 1141 if cloud and region: 1142 return ('{}/{}'.format(cloud, region),) 1143 elif region: 1144 return (region,) 1145 elif cloud: 1146 raise ValueError('The cloud must be followed by a region.') 1147 else: 1148 return () 1149 1150 def get_model_defaults(self, model_key, cloud=None, region=None): 1151 """Return a dict with information on model-defaults for model-key. 1152 1153 Giving cloud/region acts as a filter.""" 1154 cloud_region = self._format_cloud_region(cloud, region) 1155 gjo_args = ('--format', 'yaml') + cloud_region + (model_key,) 1156 raw_yaml = self.get_juju_output('model-defaults', *gjo_args, 1157 include_e=False) 1158 return yaml.safe_load(raw_yaml) 1159 1160 def set_model_defaults(self, model_key, value, cloud=None, region=None): 1161 """Set a model-defaults entry for model_key to value. 1162 1163 Giving cloud/region sets the default for that region, otherwise the 1164 controller default is set.""" 1165 cloud_region = self._format_cloud_region(cloud, region) 1166 self.juju('model-defaults', 1167 cloud_region + ('{}={}'.format(model_key, value),), 1168 include_e=False) 1169 1170 def unset_model_defaults(self, model_key, cloud=None, region=None): 1171 """Unset a model-defaults entry for model_key. 1172 1173 Giving cloud/region unsets the default for that region, otherwise the 1174 controller default is unset.""" 1175 cloud_region = self._format_cloud_region(cloud, region) 1176 self.juju('model-defaults', 1177 cloud_region + ('--reset', model_key), include_e=False) 1178 1179 def get_agent_metadata_url(self): 1180 return self.get_env_option(self.agent_metadata_url) 1181 1182 def set_testing_agent_metadata_url(self): 1183 url = self.get_agent_metadata_url() 1184 if 'testing' not in url: 1185 testing_url = url.replace('/tools', '/testing/tools') 1186 self.set_env_option(self.agent_metadata_url, testing_url) 1187 1188 def juju(self, command, args, check=True, include_e=True, 1189 timeout=None, extra_env=None, suppress_err=False): 1190 """Run a command under juju for the current environment.""" 1191 model = self._cmd_model(include_e, controller=False) 1192 return self._backend.juju( 1193 command, args, self.used_feature_flags, self.env.juju_home, 1194 model, check, timeout, extra_env, suppress_err=suppress_err) 1195 1196 def expect(self, command, args=(), include_e=True, 1197 timeout=None, extra_env=None): 1198 """Return a process object that is running an interactive `command`. 1199 1200 The interactive command ability is provided by using pexpect. 1201 1202 :param command: String of the juju command to run. 1203 :param args: Tuple containing arguments for the juju `command`. 1204 :param include_e: Boolean regarding supplying the juju environment to 1205 `command`. 1206 :param timeout: A float that, if provided, is the timeout in which the 1207 `command` is run. 1208 1209 :return: A pexpect.spawn object that has been called with `command` and 1210 `args`. 1211 1212 """ 1213 model = self._cmd_model(include_e, controller=False) 1214 return self._backend.expect( 1215 command, args, self.used_feature_flags, self.env.juju_home, 1216 model, timeout, extra_env) 1217 1218 def controller_juju(self, command, args): 1219 args = ('-c', self.env.controller.name) + args 1220 retvar, ct = self.juju(command, args, include_e=False) 1221 return CommandComplete(NoopCondition(), ct) 1222 1223 def get_juju_timings(self): 1224 timing_breakdown = [] 1225 for ct in self._backend.juju_timings: 1226 timing_breakdown.append( 1227 { 1228 'command': ct.cmd, 1229 'full_args': ct.full_args, 1230 'start': ct.start, 1231 'end': ct.end, 1232 'total_seconds': ct.total_seconds, 1233 } 1234 ) 1235 return timing_breakdown 1236 1237 def juju_async(self, command, args, include_e=True, timeout=None): 1238 model = self._cmd_model(include_e, controller=False) 1239 return self._backend.juju_async(command, args, self.used_feature_flags, 1240 self.env.juju_home, model, timeout) 1241 1242 def deploy(self, charm, repository=None, to=None, series=None, 1243 service=None, force=False, resource=None, num=None, 1244 constraints=None, alias=None, bind=None, **kwargs): 1245 args = [charm] 1246 if service is not None: 1247 args.extend([service]) 1248 if to is not None: 1249 args.extend(['--to', to]) 1250 if series is not None: 1251 args.extend(['--series', series]) 1252 if force is True: 1253 args.extend(['--force']) 1254 if resource is not None: 1255 args.extend(['--resource', resource]) 1256 if num is not None: 1257 args.extend(['-n', str(num)]) 1258 if constraints is not None: 1259 args.extend(['--constraints', constraints]) 1260 if bind is not None: 1261 args.extend(['--bind', bind]) 1262 if alias is not None: 1263 args.extend([alias]) 1264 for key, value in kwargs.items(): 1265 if isinstance(value, list): 1266 for item in value: 1267 args.extend(['--{}'.format(key), item]) 1268 else: 1269 args.extend(['--{}'.format(key), value]) 1270 retvar, ct = self.juju('deploy', tuple(args)) 1271 return retvar, CommandComplete(WaitAgentsStarted(), ct) 1272 1273 def attach(self, service, resource): 1274 args = (service, resource) 1275 retvar, ct = self.juju('attach', args) 1276 return retvar, CommandComplete(NoopCondition(), ct) 1277 1278 def list_resources(self, service_or_unit, details=True): 1279 args = ('--format', 'yaml', service_or_unit) 1280 if details: 1281 args = args + ('--details',) 1282 return yaml.safe_load(self.get_juju_output('list-resources', *args)) 1283 1284 def wait_for_resource(self, resource_id, service_or_unit, timeout=60): 1285 log.info('Waiting for resource. Resource id:{}'.format(resource_id)) 1286 with self.check_timeouts(): 1287 with self.ignore_soft_deadline(): 1288 for _ in until_timeout(timeout): 1289 resources_dict = self.list_resources(service_or_unit) 1290 resources = resources_dict['resources'] 1291 for resource in resources: 1292 if resource['expected']['resourceid'] == resource_id: 1293 if (resource['expected']['fingerprint'] == 1294 resource['unit']['fingerprint']): 1295 return 1296 time.sleep(.1) 1297 raise JujuResourceTimeout( 1298 'Timeout waiting for a resource to be downloaded. ' 1299 'ResourceId: {} Service or Unit: {} Timeout: {}'.format( 1300 resource_id, service_or_unit, timeout)) 1301 1302 def upgrade_charm(self, service, charm_path=None): 1303 args = (service,) 1304 if charm_path is not None: 1305 args = args + ('--path', charm_path) 1306 self.juju('upgrade-charm', args) 1307 1308 def remove_service(self, service): 1309 self.juju('remove-application', (service,)) 1310 1311 @classmethod 1312 def format_bundle(cls, bundle_template): 1313 return bundle_template.format(container=cls.preferred_container()) 1314 1315 def deploy_bundle(self, bundle_template, timeout=_DEFAULT_BUNDLE_TIMEOUT, static_bundle=False): 1316 """Deploy bundle using native juju 2.0 deploy command. 1317 1318 :param static_bundle: render `bundle_template` if it's not static 1319 """ 1320 if static_bundle is False: 1321 bundle_template = self.format_bundle(bundle_template) 1322 self.juju('deploy', bundle_template, timeout=timeout) 1323 1324 def deployer(self, bundle_template, name=None, deploy_delay=10, 1325 timeout=3600): 1326 """Deploy a bundle using deployer.""" 1327 bundle = self.format_bundle(bundle_template) 1328 args = ( 1329 '--debug', 1330 '--deploy-delay', str(deploy_delay), 1331 '--timeout', str(timeout), 1332 '--config', bundle, 1333 ) 1334 if name: 1335 args += (name,) 1336 e_arg = ('-e', '{}:{}'.format( 1337 self.env.controller.name, self.env.environment)) 1338 args = e_arg + args 1339 self.juju('deployer', args, include_e=False) 1340 1341 @staticmethod 1342 def _maas_spaces_enabled(): 1343 return not os.environ.get("JUJU_CI_SPACELESSNESS") 1344 1345 def _get_substrate_constraints(self): 1346 if self.env.joyent: 1347 # Only accept kvm packages by requiring >1 cpu core, see lp:1446264 1348 return 'mem=2G cpu-cores=1' 1349 elif self.env.maas and self._maas_spaces_enabled(): 1350 # For now only maas support spaces in a meaningful way. 1351 return 'mem=2G spaces={}'.format(','.join( 1352 '^' + space for space in sorted(self.excluded_spaces))) 1353 else: 1354 return 'mem=2G' 1355 1356 def quickstart(self, bundle_template, upload_tools=False): 1357 bundle = self.format_bundle(bundle_template) 1358 constraints = 'mem=2G' 1359 args = ('--constraints', constraints) 1360 if upload_tools: 1361 args = ('--upload-tools',) + args 1362 args = args + ('--no-browser', bundle,) 1363 self.juju('quickstart', args, extra_env={'JUJU': self.full_path}) 1364 1365 def status_until(self, timeout, start=None): 1366 """Call and yield status until the timeout is reached. 1367 1368 Status will always be yielded once before checking the timeout. 1369 1370 This is intended for implementing things like wait_for_started. 1371 1372 :param timeout: The number of seconds to wait before timing out. 1373 :param start: If supplied, the time to count from when determining 1374 timeout. 1375 """ 1376 with self.check_timeouts(): 1377 with self.ignore_soft_deadline(): 1378 yield self.get_status() 1379 for remaining in until_timeout(timeout, start=start): 1380 yield self.get_status() 1381 1382 def _wait_for_status(self, reporter, translate, exc_type=StatusNotMet, 1383 timeout=1200, start=None): 1384 """Wait till status reaches an expected state with pretty reporting. 1385 1386 Always tries to get status at least once. Each status call has an 1387 internal timeout of 60 seconds. This is independent of the timeout for 1388 the whole wait, note this means this function may be overrun. 1389 1390 :param reporter: A GroupReporter instance for output. 1391 :param translate: A callable that takes status to make states dict. 1392 :param exc_type: Optional StatusNotMet subclass to raise on timeout. 1393 :param timeout: Optional number of seconds to wait before timing out. 1394 :param start: Optional time to count from when determining timeout. 1395 """ 1396 status = None 1397 try: 1398 with self.check_timeouts(): 1399 with self.ignore_soft_deadline(): 1400 for _ in chain([None], 1401 until_timeout(timeout, start=start)): 1402 status = self.get_status() 1403 states = translate(status) 1404 if states is None: 1405 break 1406 status.raise_highest_error(ignore_recoverable=True) 1407 reporter.update(states) 1408 time.sleep(1) 1409 else: 1410 if status is not None: 1411 log.error(status.status_text) 1412 status.raise_highest_error( 1413 ignore_recoverable=False) 1414 raise exc_type(self.env.environment, status) 1415 finally: 1416 reporter.finish() 1417 return status 1418 1419 def wait_for_started(self, timeout=1200, start=None): 1420 """Wait until all unit/machine agents are 'started'.""" 1421 reporter = GroupReporter(sys.stdout, 'started') 1422 return self._wait_for_status( 1423 reporter, Status.check_agents_started, AgentsNotStarted, 1424 timeout=timeout, start=start) 1425 1426 def wait_for_subordinate_units(self, service, unit_prefix, timeout=1200, 1427 start=None): 1428 """Wait until all service units have a started subordinate with 1429 unit_prefix.""" 1430 def status_to_subordinate_states(status): 1431 service_unit_count = status.get_service_unit_count(service) 1432 subordinate_unit_count = 0 1433 unit_states = defaultdict(list) 1434 for name, unit in status.service_subordinate_units(service): 1435 if name.startswith(unit_prefix + '/'): 1436 subordinate_unit_count += 1 1437 unit_states[coalesce_agent_status(unit)].append(name) 1438 if (subordinate_unit_count == service_unit_count and 1439 set(unit_states.keys()).issubset(AGENTS_READY)): 1440 return None 1441 return unit_states 1442 reporter = GroupReporter(sys.stdout, 'started') 1443 self._wait_for_status( 1444 reporter, status_to_subordinate_states, AgentsNotStarted, 1445 timeout=timeout, start=start) 1446 1447 def wait_for_version(self, version, timeout=300): 1448 self.wait_for(WaitVersion(version, timeout)) 1449 1450 def list_models(self): 1451 """List the models registered with the current controller.""" 1452 self.controller_juju('list-models', ()) 1453 1454 def get_models(self): 1455 """return a models dict with a 'models': [] key-value pair. 1456 1457 The server has 120 seconds to respond because this method is called 1458 often when tearing down a controller-less deployment. 1459 """ 1460 output = self.get_juju_output( 1461 'list-models', '-c', self.env.controller.name, '--format', 'yaml', 1462 include_e=False, timeout=120) 1463 models = yaml.safe_load(output) 1464 return models 1465 1466 def _get_models(self): 1467 """return a list of model dicts.""" 1468 return self.get_models()['models'] 1469 1470 def iter_model_clients(self): 1471 """Iterate through all the models that share this model's controller""" 1472 models = self._get_models() 1473 if not models: 1474 yield self 1475 for model in models: 1476 # 2.2-rc1 introduced new model listing output name/short-name. 1477 model_name = model.get('short-name', model['name']) 1478 yield self._acquire_model_client(model_name, model.get('owner')) 1479 1480 def get_controller_model_name(self): 1481 """Return the name of the 'controller' model. 1482 1483 Return the name of the environment when an 'controller' model does 1484 not exist. 1485 """ 1486 return 'controller' 1487 1488 def _acquire_model_client(self, name, owner=None): 1489 """Get a client for a model with the supplied name. 1490 1491 If the name matches self, self is used. Otherwise, a clone is used. 1492 If the owner of the model is different to the user_name of the client 1493 provide a fully qualified model name. 1494 1495 """ 1496 if name == self.env.environment: 1497 return self 1498 else: 1499 if owner and owner != self.env.user_name: 1500 model_name = '{}/{}'.format(owner, name) 1501 else: 1502 model_name = name 1503 env = self.env.clone(model_name=model_name) 1504 return self.clone(env=env) 1505 1506 def get_model_uuid(self): 1507 name = self.env.environment 1508 model = self._cmd_model(True, False) 1509 output_yaml = self.get_juju_output( 1510 'show-model', '--format', 'yaml', model, include_e=False) 1511 output = yaml.safe_load(output_yaml) 1512 return output[name]['model-uuid'] 1513 1514 def get_controller_uuid(self): 1515 name = self.env.controller.name 1516 output_yaml = self.get_juju_output( 1517 'show-controller', 1518 name, 1519 '--format', 'yaml', 1520 include_e=False) 1521 output = yaml.safe_load(output_yaml) 1522 return output[name]['details']['uuid'] 1523 1524 def get_controller_model_uuid(self): 1525 output_yaml = self.get_juju_output( 1526 'show-model', 'controller', '--format', 'yaml', include_e=False) 1527 output = yaml.safe_load(output_yaml) 1528 return output['controller']['model-uuid'] 1529 1530 def get_controller_client(self): 1531 """Return a client for the controller model. May return self. 1532 1533 This may be inaccurate for models created using add_model 1534 rather than bootstrap. 1535 """ 1536 return self._acquire_model_client(self.get_controller_model_name()) 1537 1538 def list_controllers(self): 1539 """List the controllers.""" 1540 self.juju('list-controllers', (), include_e=False) 1541 1542 def get_controller_endpoint(self): 1543 """Return the host and port of the controller leader.""" 1544 controller = self.env.controller.name 1545 output = self.get_juju_output( 1546 'show-controller', controller, include_e=False) 1547 info = yaml.safe_load(output) 1548 endpoint = info[controller]['details']['api-endpoints'][0] 1549 return split_address_port(endpoint) 1550 1551 def get_controller_members(self): 1552 """Return a list of Machines that are members of the controller. 1553 1554 The first machine in the list is the leader. the remaining machines 1555 are followers in a HA relationship. 1556 """ 1557 members = [] 1558 status = self.get_status() 1559 for machine_id, machine in status.iter_machines(): 1560 if self.get_controller_member_status(machine): 1561 members.append(Machine(machine_id, machine)) 1562 if len(members) <= 1: 1563 return members 1564 # Search for the leader and make it the first in the list. 1565 # If the endpoint address is not the same as the leader's dns_name, 1566 # the members are return in the order they were discovered. 1567 endpoint = self.get_controller_endpoint()[0] 1568 log.debug('Controller endpoint is at {}'.format(endpoint)) 1569 members.sort(key=lambda m: m.info.get('dns-name') != endpoint) 1570 return members 1571 1572 def get_controller_leader(self): 1573 """Return the controller leader Machine.""" 1574 controller_members = self.get_controller_members() 1575 return controller_members[0] 1576 1577 @staticmethod 1578 def get_controller_member_status(info_dict): 1579 """Return the controller-member-status of the machine if it exists.""" 1580 return info_dict.get('controller-member-status') 1581 1582 def wait_for_ha(self, timeout=1200, start=None): 1583 """Wait for voiting to be enabled. 1584 1585 May only be called on a controller client.""" 1586 if self.env.environment != self.get_controller_model_name(): 1587 raise ValueError('wait_for_ha requires a controller client.') 1588 desired_state = 'has-vote' 1589 1590 def status_to_ha(status): 1591 status.check_agents_started() 1592 states = {} 1593 for machine, info in status.iter_machines(): 1594 status = self.get_controller_member_status(info) 1595 if status is None: 1596 continue 1597 states.setdefault(status, []).append(machine) 1598 if list(states.keys()) == [desired_state]: 1599 if len(states.get(desired_state, [])) >= 3: 1600 return None 1601 return states 1602 1603 reporter = GroupReporter(sys.stdout, desired_state) 1604 self._wait_for_status(reporter, status_to_ha, VotingNotEnabled, 1605 timeout=timeout, start=start) 1606 # XXX sinzui 2014-12-04: bug 1399277 happens because 1607 # juju claims HA is ready when the monogo replica sets 1608 # are not. Juju is not fully usable. The replica set 1609 # lag might be 5 minutes. 1610 self._backend.pause(300) 1611 1612 def wait_for_deploy_started(self, service_count=1, timeout=1200): 1613 """Wait until service_count services are 'started'. 1614 1615 :param service_count: The number of services for which to wait. 1616 :param timeout: The number of seconds to wait. 1617 """ 1618 with self.check_timeouts(): 1619 with self.ignore_soft_deadline(): 1620 status = None 1621 for remaining in until_timeout(timeout): 1622 status = self.get_status() 1623 if status.get_service_count() >= service_count: 1624 return 1625 time.sleep(1) 1626 else: 1627 raise ApplicationsNotStarted(self.env.environment, status) 1628 1629 def wait_for_workloads(self, timeout=600, start=None): 1630 """Wait until all unit workloads are in a ready state.""" 1631 def status_to_workloads(status): 1632 unit_states = defaultdict(list) 1633 for name, unit in status.iter_units(): 1634 workload = unit.get('workload-status') 1635 if workload is not None: 1636 state = workload['current'] 1637 else: 1638 state = 'unknown' 1639 unit_states[state].append(name) 1640 if set(('active', 'unknown')).issuperset(unit_states): 1641 return None 1642 unit_states.pop('unknown', None) 1643 return unit_states 1644 reporter = GroupReporter(sys.stdout, 'active') 1645 self._wait_for_status(reporter, status_to_workloads, WorkloadsNotReady, 1646 timeout=timeout, start=start) 1647 1648 def wait_for(self, condition, quiet=False): 1649 """Wait until the supplied conditions are satisfied. 1650 1651 The supplied conditions must be an iterable of objects like 1652 WaitMachineNotPresent. 1653 """ 1654 if condition.already_satisfied: 1655 return self.get_status() 1656 # iter_blocking_state must filter out all non-blocking values, so 1657 # there are no "expected" values for the GroupReporter. 1658 reporter = GroupReporter(sys.stdout, None) 1659 status = None 1660 try: 1661 for status in self.status_until(condition.timeout): 1662 status.raise_highest_error(ignore_recoverable=True) 1663 states = {} 1664 for item, state in condition.iter_blocking_state(status): 1665 states.setdefault(state, []).append(item) 1666 if len(states) == 0: 1667 return 1668 if not quiet: 1669 reporter.update(states) 1670 time.sleep(1) 1671 else: 1672 status.raise_highest_error(ignore_recoverable=False) 1673 except StatusTimeout: 1674 pass 1675 finally: 1676 reporter.finish() 1677 condition.do_raise(self.model_name, status) 1678 1679 def get_matching_agent_version(self): 1680 version_number = get_stripped_version_number(self.version) 1681 return version_number 1682 1683 def upgrade_juju(self, force_version=True): 1684 args = () 1685 if force_version: 1686 version = self.get_matching_agent_version() 1687 args += ('--agent-version', version) 1688 self._upgrade_juju(args) 1689 1690 def _upgrade_juju(self, args): 1691 self.juju('upgrade-juju', args) 1692 1693 def upgrade_mongo(self): 1694 self.juju('upgrade-mongo', ()) 1695 1696 def backup(self): 1697 try: 1698 # merge_stderr is required for creating a backup 1699 output = self.get_juju_output('create-backup', merge_stderr=True) 1700 except subprocess.CalledProcessError as e: 1701 log.info(e.output) 1702 raise 1703 log.info(output) 1704 backup_file_pattern = re.compile( 1705 '(juju-backup-[0-9-]+\.(t|tar.)gz)'.encode('ascii')) 1706 match = backup_file_pattern.search(output) 1707 if match is None: 1708 raise Exception("The backup file was not found in output: %s" % 1709 output) 1710 backup_file_name = match.group(1) 1711 backup_file_path = os.path.abspath(backup_file_name) 1712 log.info("State-Server backup at %s", backup_file_path) 1713 return backup_file_path.decode(getpreferredencoding()) 1714 1715 def restore_backup(self, backup_file): 1716 self.juju( 1717 'restore-backup', 1718 ('--file', backup_file)) 1719 1720 def restore_backup_async(self, backup_file): 1721 return self.juju_async('restore-backup', ('--file', backup_file)) 1722 1723 def enable_ha(self): 1724 self.juju( 1725 'enable-ha', ('-n', '3', '-c', self.env.controller.name), 1726 include_e=False) 1727 1728 def action_fetch(self, id, action=None, timeout="1m"): 1729 """Fetches the results of the action with the given id. 1730 1731 Will wait for up to 1 minute for the action results. 1732 The action name here is just used for an more informational error in 1733 cases where it's available. 1734 Returns the yaml output of the fetched action. 1735 """ 1736 out = self.get_juju_output("show-action-output", id, "--wait", timeout) 1737 status = yaml.safe_load(out)["status"] 1738 if status != "completed": 1739 action_name = '' if not action else ' "{}"'.format(action) 1740 raise Exception( 1741 'Timed out waiting for action{} to complete during fetch ' 1742 'with status: {}.'.format(action_name, status)) 1743 return out 1744 1745 def action_do(self, unit, action, *args): 1746 """Performs the given action on the given unit. 1747 1748 Action params should be given as args in the form foo=bar. 1749 Returns the id of the queued action. 1750 """ 1751 args = (unit, action) + args 1752 1753 output = self.get_juju_output("run-action", *args) 1754 action_id_pattern = re.compile( 1755 'Action queued with id: ([a-f0-9\-]{36})') 1756 match = action_id_pattern.search(output) 1757 if match is None: 1758 raise Exception("Action id not found in output: %s" % 1759 output) 1760 return match.group(1) 1761 1762 def action_do_fetch(self, unit, action, timeout="1m", *args): 1763 """Performs given action on given unit and waits for the results. 1764 1765 Action params should be given as args in the form foo=bar. 1766 Returns the yaml output of the action. 1767 """ 1768 id = self.action_do(unit, action, *args) 1769 return self.action_fetch(id, action, timeout) 1770 1771 def run(self, commands, applications=None, machines=None, units=None, 1772 use_json=True): 1773 args = [] 1774 if use_json: 1775 args.extend(['--format', 'json']) 1776 if applications is not None: 1777 args.extend(['--application', ','.join(applications)]) 1778 if machines is not None: 1779 args.extend(['--machine', ','.join(machines)]) 1780 if units is not None: 1781 args.extend(['--unit', ','.join(units)]) 1782 args.extend(commands) 1783 responses = self.get_juju_output('run', *args) 1784 if use_json: 1785 return json.loads(responses) 1786 else: 1787 return responses 1788 1789 def list_space(self): 1790 return yaml.safe_load(self.get_juju_output('list-space')) 1791 1792 def add_space(self, space): 1793 self.juju('add-space', (space),) 1794 1795 def add_subnet(self, subnet, space): 1796 self.juju('add-subnet', (subnet, space)) 1797 1798 def is_juju1x(self): 1799 return self.version.startswith('1.') 1800 1801 def _get_register_command(self, output): 1802 """Return register token from add-user output. 1803 1804 Return the register token supplied within the output from the add-user 1805 command. 1806 1807 """ 1808 for row in output.split('\n'): 1809 if 'juju register' in row: 1810 command_string = row.strip().lstrip() 1811 command_parts = command_string.split(' ') 1812 return command_parts[-1] 1813 raise AssertionError('Juju register command not found in output') 1814 1815 def add_user(self, username): 1816 """Adds provided user and return register command arguments. 1817 1818 :return: Registration token provided by the add-user command. 1819 """ 1820 output = self.get_juju_output( 1821 'add-user', username, '-c', self.env.controller.name, 1822 include_e=False) 1823 return self._get_register_command(output) 1824 1825 def add_user_perms(self, username, models=None, permissions='login'): 1826 """Adds provided user and return register command arguments. 1827 1828 :return: Registration token provided by the add-user command. 1829 """ 1830 output = self.add_user(username) 1831 self.grant(username, permissions, models) 1832 return output 1833 1834 def revoke(self, username, models=None, permissions='read'): 1835 if models is None: 1836 models = self.env.environment 1837 1838 args = (username, permissions, models) 1839 1840 self.controller_juju('revoke', args) 1841 1842 def add_storage(self, unit, storage_type, amount="1"): 1843 """Add storage instances to service. 1844 1845 Only type 'disk' is able to add instances. 1846 """ 1847 self.juju('add-storage', (unit, storage_type + "=" + amount)) 1848 1849 def list_storage(self): 1850 """Return the storage list.""" 1851 return self.get_juju_output('list-storage', '--format', 'json') 1852 1853 def list_storage_pool(self): 1854 """Return the list of storage pool.""" 1855 return self.get_juju_output('list-storage-pools', '--format', 'json') 1856 1857 def create_storage_pool(self, name, provider, size): 1858 """Create storage pool.""" 1859 self.juju('create-storage-pool', 1860 (name, provider, 1861 'size={}'.format(size))) 1862 1863 def disable_user(self, user_name): 1864 """Disable an user""" 1865 self.controller_juju('disable-user', (user_name,)) 1866 1867 def enable_user(self, user_name): 1868 """Enable an user""" 1869 self.controller_juju('enable-user', (user_name,)) 1870 1871 def logout(self): 1872 """Logout an user""" 1873 self.controller_juju('logout', ()) 1874 self.env.user_name = '' 1875 1876 def _end_pexpect_session(self, session): 1877 """Pexpect doesn't return buffers, or handle exceptions well. 1878 This method attempts to ensure any relevant data is returned to the 1879 test output in the event of a failure, or the unexpected""" 1880 session.expect(pexpect.EOF) 1881 session.close() 1882 if session.exitstatus != 0: 1883 log.error('Buffer: {}'.format(session.buffer)) 1884 log.error('Before: {}'.format(session.before)) 1885 raise Exception('pexpect process exited with {}'.format( 1886 session.exitstatus)) 1887 1888 def register_user(self, user, juju_home, controller_name=None): 1889 """Register `user` for the `client` return the cloned client used.""" 1890 username = user.name 1891 if controller_name is None: 1892 controller_name = '{}_controller'.format(username) 1893 1894 model = self.env.environment 1895 token = self.add_user_perms(username, models=model, 1896 permissions=user.permissions) 1897 user_client = self.create_cloned_environment(juju_home, 1898 controller_name, 1899 username) 1900 user_client.env.user_name = username 1901 register_user_interactively(user_client, token, controller_name) 1902 return user_client 1903 1904 def login_user(self, username=None, password=None): 1905 """Login `user` for the `client`""" 1906 if username is None: 1907 username = self.env.user_name 1908 1909 self.env.user_name = username 1910 1911 if password is None: 1912 password = '{}-{}'.format(username, 'password') 1913 1914 try: 1915 child = self.expect(self.login_user_command, 1916 (username, '-c', self.env.controller.name), 1917 include_e=False) 1918 child.expect('(?i)password') 1919 child.sendline(password) 1920 self._end_pexpect_session(child) 1921 except pexpect.TIMEOUT: 1922 log.error('Buffer: {}'.format(child.buffer)) 1923 log.error('Before: {}'.format(child.before)) 1924 raise Exception( 1925 'FAIL Login user failed: pexpect session timed out') 1926 1927 def register_host(self, host, email, password): 1928 child = self.expect('register', ('--no-browser-login', host), 1929 include_e=False) 1930 try: 1931 child.logfile = sys.stdout 1932 child.expect('E-Mail:|Enter a name for this controller:') 1933 if child.match.group(0) == 'E-Mail:': 1934 child.sendline(email) 1935 child.expect('Password:') 1936 child.logfile = None 1937 try: 1938 child.sendline(password) 1939 finally: 1940 child.logfile = sys.stdout 1941 child.expect(r'Two-factor auth \(Enter for none\):') 1942 child.sendline() 1943 child.expect('Enter a name for this controller:') 1944 child.sendline(self.env.controller.name) 1945 self._end_pexpect_session(child) 1946 except pexpect.TIMEOUT: 1947 log.error('Buffer: {}'.format(child.buffer)) 1948 log.error('Before: {}'.format(child.before)) 1949 raise Exception( 1950 'Registering host failed: pexpect session timed out') 1951 1952 def remove_user(self, username): 1953 self.juju('remove-user', (username, '-y'), include_e=False) 1954 1955 def create_cloned_environment( 1956 self, cloned_juju_home, controller_name, user_name=None): 1957 """Create a cloned environment. 1958 1959 If `user_name` is passed ensures that the cloned environment is updated 1960 to match. 1961 1962 """ 1963 user_client = self.clone(env=self.env.clone()) 1964 user_client.env.juju_home = cloned_juju_home 1965 if user_name is not None and user_name != self.env.user_name: 1966 user_client.env.user_name = user_name 1967 user_client.env.environment = qualified_model_name( 1968 user_client.env.environment, self.env.user_name) 1969 user_client.env.dump_yaml(user_client.env.juju_home) 1970 # New user names the controller. 1971 user_client.env.controller = Controller(controller_name) 1972 return user_client 1973 1974 def grant(self, user_name, permission, model=None): 1975 """Grant the user with model or controller permission.""" 1976 if permission in self.ignore_permissions: 1977 log.info('Ignoring permission "{}".'.format(permission)) 1978 return 1979 if permission in self.controller_permissions: 1980 self.juju( 1981 'grant', 1982 (user_name, permission, '-c', self.env.controller.name), 1983 include_e=False) 1984 elif permission in self.model_permissions: 1985 if model is None: 1986 model = self.model_name 1987 self.juju( 1988 'grant', 1989 (user_name, permission, model, '-c', self.env.controller.name), 1990 include_e=False) 1991 else: 1992 raise ValueError('Unknown permission {}'.format(permission)) 1993 1994 def list_clouds(self, format='json'): 1995 """List all the available clouds.""" 1996 return self.get_juju_output('list-clouds', '--format', 1997 format, include_e=False) 1998 1999 def generate_tool(self, source_dir, stream=None): 2000 args = ('generate-tools', '-d', source_dir) 2001 if stream is not None: 2002 args += ('--stream', stream) 2003 retvar, ct = self.juju('metadata', args, include_e=False) 2004 return retvar, CommandComplete(NoopCondition(), ct) 2005 2006 def add_cloud(self, cloud_name, cloud_file): 2007 retvar, ct = self.juju( 2008 'add-cloud', ("--replace", cloud_name, cloud_file), 2009 include_e=False) 2010 return retvar, CommandComplete(NoopCondition(), ct) 2011 2012 def add_cloud_interactive(self, cloud_name, cloud): 2013 child = self.expect('add-cloud', include_e=False) 2014 try: 2015 child.logfile = sys.stdout 2016 child.expect('Select cloud type:') 2017 child.sendline(cloud['type']) 2018 child.expect('(Enter a name for your .* cloud:)|' 2019 '(Select cloud type:)') 2020 if child.match.group(2) is not None: 2021 raise TypeNotAccepted('Cloud type not accepted.') 2022 child.sendline(cloud_name) 2023 if cloud['type'] == 'maas': 2024 child.expect('Enter the API endpoint url:') 2025 child.sendline(cloud['endpoint']) 2026 if cloud['type'] == 'manual': 2027 child.expect( 2028 "(Enter the controller's hostname or IP address:)|" 2029 "(Enter a name for your .* cloud:)") 2030 if child.match.group(2) is not None: 2031 raise NameNotAccepted('Cloud name not accepted.') 2032 child.sendline(cloud['endpoint']) 2033 if cloud['type'] == 'openstack': 2034 child.expect('Enter the API endpoint url for the cloud:') 2035 child.sendline(cloud['endpoint']) 2036 child.expect( 2037 "(Select one or more auth types separated by commas:)|" 2038 "(Can't validate endpoint)") 2039 if child.match.group(2) is not None: 2040 raise InvalidEndpoint() 2041 child.sendline(','.join(cloud['auth-types'])) 2042 for num, (name, values) in enumerate(cloud['regions'].items()): 2043 child.expect( 2044 '(Enter region name:)|(Select one or more auth types' 2045 ' separated by commas:)') 2046 if child.match.group(2) is not None: 2047 raise AuthNotAccepted('Auth was not compatible.') 2048 child.sendline(name) 2049 child.expect(self.REGION_ENDPOINT_PROMPT) 2050 child.sendline(values['endpoint']) 2051 child.expect("(Enter another region\? \(Y/n\):)|" 2052 "(Can't validate endpoint)") 2053 if child.match.group(2) is not None: 2054 raise InvalidEndpoint() 2055 if num + 1 < len(cloud['regions']): 2056 child.sendline('y') 2057 else: 2058 child.sendline('n') 2059 if cloud['type'] == 'vsphere': 2060 child.expect( 2061 'Enter the ' 2062 '(vCenter address or URL|API endpoint url for the cloud):') 2063 child.sendline(cloud['endpoint']) 2064 for num, (name, values) in enumerate(cloud['regions'].items()): 2065 child.expect("Enter (datacenter|region) name:|" 2066 "(?P<invalid>Can't validate endpoint)") 2067 if child.match.group('invalid') is not None: 2068 raise InvalidEndpoint() 2069 child.sendline(name) 2070 child.expect( 2071 'Enter another (datacenter|region)\? \(Y/n\):') 2072 if num + 1 < len(cloud['regions']): 2073 child.sendline('y') 2074 else: 2075 child.sendline('n') 2076 2077 child.expect([pexpect.EOF, "Can't validate endpoint"]) 2078 if child.match != pexpect.EOF: 2079 if child.match.group(0) == "Can't validate endpoint": 2080 raise InvalidEndpoint() 2081 except pexpect.TIMEOUT: 2082 raise Exception( 2083 'Adding cloud failed: pexpect session timed out') 2084 2085 def show_controller(self, format='json'): 2086 """Show controller's status.""" 2087 return self.get_juju_output('show-controller', '--format', 2088 format, include_e=False) 2089 2090 def show_machine(self, machine): 2091 """Return data on a machine as a dict.""" 2092 text = self.get_juju_output('show-machine', machine, 2093 '--format', 'yaml') 2094 return yaml.safe_load(text) 2095 2096 def ssh_keys(self, full=False): 2097 """Give the ssh keys registered for the current model.""" 2098 args = [] 2099 if full: 2100 args.append('--full') 2101 return self.get_juju_output('ssh-keys', *args) 2102 2103 def add_ssh_key(self, *keys): 2104 """Add one or more ssh keys to the current model.""" 2105 return self.get_juju_output('add-ssh-key', *keys, merge_stderr=True) 2106 2107 def remove_ssh_key(self, *keys): 2108 """Remove one or more ssh keys from the current model.""" 2109 return self.get_juju_output('remove-ssh-key', *keys, merge_stderr=True) 2110 2111 def import_ssh_key(self, *keys): 2112 """Import ssh keys from one or more identities to the current model.""" 2113 return self.get_juju_output('import-ssh-key', *keys, merge_stderr=True) 2114 2115 def list_disabled_commands(self): 2116 """List all the commands disabled on the model.""" 2117 raw = self.get_juju_output('list-disabled-commands', 2118 '--format', 'yaml') 2119 return yaml.safe_load(raw) 2120 2121 def disable_command(self, command_set, message=''): 2122 """Disable a command-set.""" 2123 retvar, ct = self.juju('disable-command', (command_set, message)) 2124 return retvar, CommandComplete(NoopCondition(), ct) 2125 2126 def enable_command(self, args): 2127 """Enable a command-set.""" 2128 retvar, ct = self.juju('enable-command', args) 2129 return CommandComplete(NoopCondition(), ct) 2130 2131 def sync_tools(self, local_dir=None, stream=None, source=None): 2132 """Copy tools into a local directory or model.""" 2133 args = () 2134 if stream is not None: 2135 args += ('--stream', stream) 2136 if source is not None: 2137 args += ('--source', source) 2138 if local_dir is None: 2139 retvar, ct = self.juju('sync-tools', args) 2140 return retvar, CommandComplete(NoopCondition(), ct) 2141 else: 2142 args += ('--local-dir', local_dir) 2143 retvar, ct = self.juju('sync-tools', args, include_e=False) 2144 return retvar, CommandComplete(NoopCondition(), ct) 2145 2146 def switch(self, model=None, controller=None): 2147 """Switch between models.""" 2148 args = [x for x in [controller, model] if x] 2149 if not args: 2150 raise ValueError('No target to switch to has been given.') 2151 self.juju('switch', (':'.join(args),), include_e=False) 2152 2153 2154 # caas `add-k8s` did not implement parsing kube config path via flag, 2155 # so parse it via env var -> https://github.com/kubernetes/client-go/blob/master/tools/clientcmd/loader.go#L44 2156 KUBE_CONFIG_PATH_ENV_VAR = 'KUBECONFIG' 2157 2158 2159 class CaasClient: 2160 """CaasClient defines a client that can interact with CAAS setup directly. 2161 Methods and properties that solely interact with a kubernetes 2162 infrastructure can then be added to the following class. 2163 """ 2164 2165 cloud_name = 'k8cloud' 2166 2167 def __init__(self, client): 2168 self.client = client 2169 self.juju_home = self.client.env.juju_home 2170 2171 self.kubectl_path = os.path.join(self.juju_home, 'kubectl') 2172 self.kube_home = os.path.join(self.juju_home, '.kube') 2173 self.kube_config_path = os.path.join(self.kube_home, 'config') 2174 2175 # ensure kube home 2176 ensure_dir(self.kube_home) 2177 2178 # ensure kube config env var 2179 os.environ[KUBE_CONFIG_PATH_ENV_VAR] = self.kube_config_path 2180 2181 # ensure kube credentials 2182 self.client.juju('scp', ('kubernetes-master/0:config', self.kube_config_path)) 2183 2184 # ensure kubectl by scp from master 2185 self.client.juju('scp', ('kubernetes-master/0:/snap/kubectl/current/kubectl', self.kubectl_path)) 2186 2187 self.client.controller_juju('add-k8s', (self.cloud_name,)) 2188 log.debug('added caas cloud, now all clouds are -> \n%s', self.client.list_clouds(format='yaml')) 2189 2190 def add_model(self, model_name): 2191 return self.client.add_model(env=self.client.env.clone(model_name), cloud_region=self.cloud_name) 2192 2193 @property 2194 def is_cluster_healthy(self): 2195 try: 2196 cluster_info = self.kubectl('cluster-info') 2197 log.debug('cluster_info -> \n%s', cluster_info) 2198 nodes_info = self.kubectl('get', 'nodes') 2199 log.debug('nodes_info -> \n%s', nodes_info) 2200 return True 2201 except subprocess.CalledProcessError as e: 2202 log.error('error -> %s', e) 2203 return False 2204 2205 def kubectl(self, *args): 2206 args = (self.kubectl_path, '--kubeconfig', self.kube_config_path) + args 2207 return subprocess.check_output(args, stderr=subprocess.STDOUT).decode('UTF-8').strip() 2208 2209 def kubectl_apply(self, stdin): 2210 with subprocess.Popen(('echo', stdin), stdout=subprocess.PIPE) as echo: 2211 o = subprocess.check_output( 2212 (self.kubectl_path, '--kubeconfig', self.kube_config_path, 'apply', '-f', '-'), 2213 stdin=echo.stdout 2214 ).decode('UTF-8').strip() 2215 log.debug(o) 2216 2217 def get_external_hostname(self): 2218 # assume here always use single node cdk core or microk8s 2219 return '{}.xip.io'.format(self.get_first_worker_ip()) 2220 2221 def get_first_worker_ip(self): 2222 status = self.client.get_status() 2223 unit = status.get_unit('kubernetes-worker/{}'.format(0)) 2224 return status.get_machine_dns_name(unit['machine']) 2225 2226 2227 class IaasClient: 2228 """IaasClient defines a client that can interact with IAAS setup directly. 2229 """ 2230 2231 def __init__(self, client): 2232 self.client = client 2233 self.juju_home = self.client.env.juju_home 2234 2235 def add_model(self, model_name): 2236 return self.client.add_model(env=self.client.env.clone(model_name)) 2237 2238 @property 2239 def is_cluster_healthy(self): 2240 return True 2241 2242 def register_user_interactively(client, token, controller_name): 2243 """Register a user with the supplied token and controller name. 2244 2245 :param client: ModelClient on which to register the user (using the models 2246 controller.) 2247 :param token: Token string to use when registering. 2248 :param controller_name: String to use when naming the controller. 2249 """ 2250 try: 2251 child = client.expect('register', (token), include_e=False) 2252 child.expect('Enter a new password:') 2253 child.sendline(client.env.user_name + '_password') 2254 child.expect('Confirm password:') 2255 child.sendline(client.env.user_name + '_password') 2256 child.expect('Enter a name for this controller \[.*\]:') 2257 child.sendline(controller_name) 2258 client._end_pexpect_session(child) 2259 except pexpect.TIMEOUT: 2260 log.error('Buffer: {}'.format(child.buffer)) 2261 log.error('Before: {}'.format(child.before)) 2262 raise Exception( 2263 'Registering user failed: pexpect session timed out') 2264 2265 2266 def juju_home_path(juju_home, dir_name): 2267 return os.path.join(juju_home, 'juju-homes', dir_name) 2268 2269 2270 def get_cache_path(juju_home, models=False): 2271 if models: 2272 root = os.path.join(juju_home, 'models') 2273 else: 2274 root = os.path.join(juju_home, 'environments') 2275 return os.path.join(root, 'cache.yaml') 2276 2277 2278 def make_safe_config(client): 2279 config = client.env.make_config_copy() 2280 if 'agent-version' in client.bootstrap_replaces: 2281 config.pop('agent-version', None) 2282 else: 2283 config['agent-version'] = client.get_matching_agent_version() 2284 # AFAICT, we *always* want to set test-mode to True. If we ever find a 2285 # use-case where we don't, we can make this optional. 2286 config['test-mode'] = True 2287 # Explicitly set 'name', which Juju implicitly sets to env.environment to 2288 # ensure MAASAccount knows what the name will be. 2289 config['name'] = unqualified_model_name(client.env.environment) 2290 # Pass the logging config into the yaml file 2291 if client.env.logging_config is not None: 2292 config['logging-config'] = client.env.logging_config 2293 2294 return config 2295 2296 2297 @contextmanager 2298 def temp_bootstrap_env(juju_home, client): 2299 """Create a temporary environment for bootstrapping. 2300 2301 This involves creating a temporary juju home directory and returning its 2302 location. 2303 2304 :param juju_home: The current JUJU_HOME value. 2305 :param client: The client being prepared for bootstrapping. 2306 :param set_home: Set JUJU_HOME to match the temporary home in this 2307 context. If False, juju_home should be supplied to bootstrap. 2308 """ 2309 # Always bootstrap a matching environment. 2310 context = client.env.make_juju_home(juju_home, client.env.environment) 2311 with context as temp_juju_home: 2312 client.env.juju_home = temp_juju_home 2313 yield temp_juju_home 2314 2315 2316 def get_machine_dns_name(client, machine, timeout=600): 2317 """Wait for dns-name on a juju machine.""" 2318 for status in client.status_until(timeout=timeout): 2319 try: 2320 return _dns_name_for_machine(status, machine) 2321 except KeyError: 2322 log.debug("No dns-name yet for machine %s", machine) 2323 2324 2325 class Controller: 2326 """Represents the controller for a model or models.""" 2327 2328 def __init__(self, name): 2329 self.name = name 2330 self.explicit_region = False 2331 2332 2333 class GroupReporter: 2334 2335 def __init__(self, stream, expected): 2336 self.stream = stream 2337 self.expected = expected 2338 self.last_group = None 2339 self.ticks = 0 2340 self.wrap_offset = 0 2341 self.wrap_width = 79 2342 2343 def _write(self, string): 2344 self.stream.write(string) 2345 self.stream.flush() 2346 2347 def finish(self): 2348 if self.last_group: 2349 self._write("\n") 2350 2351 def update(self, group): 2352 if group == self.last_group: 2353 if (self.wrap_offset + self.ticks) % self.wrap_width == 0: 2354 self._write("\n") 2355 self._write("." if self.ticks or not self.wrap_offset else " .") 2356 self.ticks += 1 2357 return 2358 value_listing = [] 2359 for value, entries in sorted(group.items()): 2360 if value == self.expected: 2361 continue 2362 value_listing.append('%s: %s' % (value, ', '.join(entries))) 2363 string = ' | '.join(value_listing) 2364 lead_length = len(string) + 1 2365 if self.last_group: 2366 string = "\n" + string 2367 self._write(string) 2368 self.last_group = group 2369 self.ticks = 0 2370 self.wrap_offset = lead_length if lead_length < self.wrap_width else 0 2371 2372 2373 def _get_full_path(juju_path): 2374 """Helper to ensure a full path is used. 2375 2376 If juju_path is None, ModelClient.get_full_path is used. Otherwise, 2377 the supplied path is converted to absolute. 2378 """ 2379 if juju_path is None: 2380 return ModelClient.get_full_path() 2381 else: 2382 return os.path.abspath(juju_path) 2383 2384 2385 def client_from_config(config, juju_path, debug=False, soft_deadline=None): 2386 """Create a client from an environment's configuration. 2387 2388 :param config: Name of the environment to use the config from. 2389 :param juju_path: Path to juju binary the client should wrap. 2390 :param debug=False: The debug flag for the client, False by default. 2391 :param soft_deadline: A datetime representing the deadline by which 2392 normal operations should complete. If None, no deadline is 2393 enforced. 2394 """ 2395 2396 version = ModelClient.get_version(juju_path) 2397 if config is None: 2398 env = ModelClient.config_class('', {}) 2399 else: 2400 env = ModelClient.config_class.from_config(config) 2401 full_path = _get_full_path(juju_path) 2402 return ModelClient( 2403 env, version, full_path, debug=debug, soft_deadline=soft_deadline) 2404 2405 2406 def client_for_existing(juju_path, juju_data_dir, debug=False, 2407 soft_deadline=None, controller_name=None, 2408 model_name=None): 2409 """Create a client for an existing controller/model. 2410 2411 :param juju_path: Path to juju binary the client should wrap. 2412 :param juju_data_dir: Path to the juju data directory referring the the 2413 controller and model. 2414 :param debug=False: The debug flag for the client, False by default. 2415 :param soft_deadline: A datetime representing the deadline by which 2416 normal operations should complete. If None, no deadline is 2417 enforced. 2418 """ 2419 version = ModelClient.get_version(juju_path) 2420 full_path = _get_full_path(juju_path) 2421 backend = ModelClient.default_backend( 2422 full_path, version, set(), debug=debug, soft_deadline=soft_deadline) 2423 if controller_name is None: 2424 current_controller = backend.get_active_controller(juju_data_dir) 2425 controller_name = current_controller 2426 if model_name is None: 2427 current_model = backend.get_active_model(juju_data_dir) 2428 model_name = current_model 2429 config = ModelClient.config_class.for_existing( 2430 juju_data_dir, controller_name, model_name) 2431 return ModelClient( 2432 config, version, full_path, 2433 debug=debug, soft_deadline=soft_deadline, _backend=backend)