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)