github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/jujupy/fake.py (about)

     1  # This file is part of JujuPy, a library for driving the Juju CLI.
     2  # Copyright 2016-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 argparse import ArgumentParser
    18  from base64 import b64encode
    19  from contextlib import contextmanager
    20  import copy
    21  from hashlib import sha512
    22  from itertools import count
    23  import json
    24  import logging
    25  import re
    26  import subprocess
    27  import uuid
    28  
    29  import pexpect
    30  import yaml
    31  
    32  from jujupy import (
    33      ModelClient,
    34      JujuData,
    35  )
    36  from jujupy.exceptions import (
    37      SoftDeadlineExceeded,
    38  )
    39  from jujupy.wait_condition import (
    40      CommandTime,
    41      )
    42  
    43  __metaclass__ = type
    44  
    45  # Python 2 and 3 compatibility
    46  try:
    47      argtype = basestring
    48  except NameError:
    49      argtype = str
    50  
    51  
    52  class ControllerOperation(Exception):
    53  
    54      def __init__(self, operation):
    55          super(ControllerOperation, self).__init__(
    56              'Operation "{}" is only valid on controller models.'.format(
    57                  operation))
    58  
    59  
    60  def assert_juju_call(test_case, mock_method, client, expected_args,
    61                       call_index=None):
    62      if call_index is None:
    63          test_case.assertEqual(len(mock_method.mock_calls), 1)
    64          call_index = 0
    65      empty, args, kwargs = mock_method.mock_calls[call_index]
    66      test_case.assertEqual(args, (expected_args,))
    67  
    68  
    69  class FakeControllerState:
    70  
    71      def __init__(self):
    72          self.name = 'name'
    73          self.state = 'not-bootstrapped'
    74          self.models = {}
    75          self.users = {
    76              'admin': {
    77                  'state': '',
    78                  'permission': 'write'
    79              }
    80          }
    81          self.shares = ['admin']
    82          self.active_model = None
    83  
    84      def add_model(self, name):
    85          state = FakeEnvironmentState(self)
    86          state.name = name
    87          self.models[name] = state
    88          state.controller.state = 'created'
    89          return state
    90  
    91      def require_controller(self, operation, name):
    92          if name != self.controller_model.name:
    93              raise ControllerOperation(operation)
    94  
    95      def grant(self, username, permission):
    96          model_permissions = ['read', 'write', 'admin']
    97          if permission in model_permissions:
    98              permission = 'login'
    99          self.users[username]['access'] = permission
   100  
   101      def add_user_perms(self, username, permissions):
   102          self.users.update(
   103              {username: {'state': '', 'permission': permissions}})
   104          self.shares.append(username)
   105  
   106      def bootstrap(self, model_name, config):
   107          default_model = self.add_model(model_name)
   108          default_model.name = model_name
   109          controller_model = default_model.controller.add_model('controller')
   110          self.controller_model = controller_model
   111          controller_model.state_servers.append(controller_model.add_machine())
   112          self.state = 'bootstrapped'
   113          default_model.model_config = copy.deepcopy(config)
   114          self.models[default_model.name] = default_model
   115          return default_model
   116  
   117      def register(self, name, email, password, twofa):
   118          self.name = name
   119          self.add_user_perms('jrandom@external', 'write')
   120          self.users['jrandom@external'].update(
   121              {'email': email, 'password': password, '2fa': twofa})
   122          self.state = 'registered'
   123  
   124      def login_user(self, name, password):
   125          self.name = name
   126          self.users.update(
   127              {name: {'password': password}})
   128  
   129      def destroy(self, kill=False):
   130          for model in list(self.models.values()):
   131              model.destroy_model()
   132          self.models.clear()
   133          if kill:
   134              self.state = 'controller-killed'
   135          else:
   136              self.state = 'controller-destroyed'
   137  
   138  
   139  class FakeEnvironmentState:
   140      """A Fake environment state that can be used by multiple FakeBackends."""
   141  
   142      def __init__(self, controller=None):
   143          self._clear()
   144          if controller is not None:
   145              self.controller = controller
   146          else:
   147              self.controller = FakeControllerState()
   148  
   149      def _clear(self):
   150          self.name = None
   151          self.machine_id_iter = count()
   152          self.state_servers = []
   153          self.services = {}
   154          self.machines = set()
   155          self.containers = {}
   156          self.relations = {}
   157          self.token = None
   158          self.exposed = set()
   159          self.machine_host_names = {}
   160          self.current_bundle = None
   161          self.model_config = None
   162          self.ssh_keys = []
   163  
   164      @property
   165      def state(self):
   166          return self.controller.state
   167  
   168      def add_machine(self, host_name=None, machine_id=None):
   169          if machine_id is None:
   170              machine_id = str(next(self.machine_id_iter))
   171          self.machines.add(machine_id)
   172          if host_name is None:
   173              host_name = '{}.example.com'.format(machine_id)
   174          self.machine_host_names[machine_id] = host_name
   175          return machine_id
   176  
   177      def add_ssh_machines(self, machines):
   178          for machine in machines:
   179              self.add_machine()
   180  
   181      def add_container(self, container_type, host=None, container_num=None):
   182          if host is None:
   183              host = self.add_machine()
   184          host_containers = self.containers.setdefault(host, set())
   185          if container_num is None:
   186              same_type_containers = [x for x in host_containers if
   187                                      container_type in x]
   188              container_num = len(same_type_containers)
   189          container_name = '{}/{}/{}'.format(host, container_type, container_num)
   190          host_containers.add(container_name)
   191          host_name = '{}.example.com'.format(container_name)
   192          self.machine_host_names[container_name] = host_name
   193  
   194      def remove_container(self, container_id):
   195          for containers in self.containers.values():
   196              containers.discard(container_id)
   197  
   198      def remove_machine(self, machine_id, force=False):
   199          if not force:
   200              for units, unit_id, loop_machine_id in self.iter_unit_machines():
   201                  if loop_machine_id != machine_id:
   202                      continue
   203                  logging.error(
   204                      'no machines were destroyed: machine {} has unit "{}"'
   205                      ' assigned'.format(machine_id, unit_id))
   206                  raise subprocess.CalledProcessError(1, 'machine assigned.')
   207          self.machines.remove(machine_id)
   208          self.containers.pop(machine_id, None)
   209  
   210      def destroy_model(self):
   211          del self.controller.models[self.name]
   212          self._clear()
   213          self.controller.state = 'model-destroyed'
   214  
   215      def _fail_stderr(self, message, returncode=1, cmd='juju', stdout=''):
   216          exc = subprocess.CalledProcessError(returncode, cmd, stdout)
   217          exc.stderr = message
   218          raise exc
   219  
   220      def restore_backup(self):
   221          self.controller.require_controller('restore', self.name)
   222          if len(self.state_servers) > 0:
   223              return self._fail_stderr('Operation not permitted')
   224          self.state_servers.append(self.add_machine())
   225  
   226      def enable_ha(self):
   227          self.controller.require_controller('enable-ha', self.name)
   228          for n in range(2):
   229              self.state_servers.append(self.add_machine())
   230  
   231      def deploy(self, charm_name, service_name):
   232          self.add_unit(service_name)
   233  
   234      def deploy_bundle(self, bundle_path):
   235          self.current_bundle = bundle_path
   236  
   237      def add_unit(self, service_name):
   238          machines = self.services.setdefault(service_name, set())
   239          machines.add(
   240              ('{}/{}'.format(service_name, str(len(machines))),
   241               self.add_machine()))
   242  
   243      def iter_unit_machines(self):
   244          for units in self.services.values():
   245              for unit_id, machine_id in units:
   246                  yield units, unit_id, machine_id
   247  
   248      def remove_unit(self, to_remove):
   249          for units, unit_id, machine_id in self.iter_unit_machines():
   250              if unit_id == to_remove:
   251                  units.remove((unit_id, machine_id))
   252                  self.remove_machine(machine_id)
   253                  break
   254          else:
   255              raise subprocess.CalledProcessError(
   256                  1, 'juju remove-unit {}'.format(unit_id))
   257  
   258      def destroy_service(self, service_name):
   259          for unit, machine_id in self.services.pop(service_name):
   260              self.remove_machine(machine_id)
   261  
   262      def get_status_dict(self):
   263          machines = {}
   264          for machine_id in self.machines:
   265              machine_dict = {
   266                  'juju-status': {'current': 'idle'},
   267                  'series': 'angsty',
   268                  }
   269              hostname = self.machine_host_names.get(machine_id)
   270              machine_dict['instance-id'] = machine_id
   271              if hostname is not None:
   272                  machine_dict['dns-name'] = hostname
   273              machines[machine_id] = machine_dict
   274              if machine_id in self.state_servers:
   275                  machine_dict['controller-member-status'] = 'has-vote'
   276          for host, containers in self.containers.items():
   277              container_dict = dict((c, {'series': 'angsty'})
   278                                    for c in containers)
   279              for container, subdict in container_dict.items():
   280                  subdict.update({'juju-status': {'current': 'idle'}})
   281                  dns_name = self.machine_host_names.get(container)
   282                  if dns_name is not None:
   283                      subdict['dns-name'] = dns_name
   284  
   285              machines[host]['containers'] = container_dict
   286          services = {}
   287          for service, units in self.services.items():
   288              unit_map = {}
   289              for unit_id, machine_id in units:
   290                  unit_map[unit_id] = {
   291                      'machine': machine_id,
   292                      'juju-status': {'current': 'idle'}}
   293              services[service] = {
   294                  'units': unit_map,
   295                  'relations': self.relations.get(service, {}),
   296                  'exposed': service in self.exposed,
   297                  }
   298          return {
   299              'machines': machines,
   300              'applications': services,
   301              'model': {'name': self.name},
   302              }
   303  
   304      def add_ssh_key(self, keys_to_add):
   305          errors = []
   306          for key in keys_to_add:
   307              if not key.startswith("ssh-rsa "):
   308                  errors.append(
   309                      'cannot add key "{0}": invalid ssh key: {0}'.format(key))
   310              elif key in self.ssh_keys:
   311                  errors.append(
   312                      'cannot add key "{0}": duplicate ssh key: {0}'.format(key))
   313              else:
   314                  self.ssh_keys.append(key)
   315          return '\n'.join(errors)
   316  
   317      def remove_ssh_key(self, keys_to_remove):
   318          errors = []
   319          for i in reversed(range(len(keys_to_remove))):
   320              key = keys_to_remove[i]
   321              if key in ('juju-client-key', 'juju-system-key'):
   322                  keys_to_remove = keys_to_remove[:i] + keys_to_remove[i + 1:]
   323                  errors.append(
   324                      'cannot remove key id "{0}": may not delete internal key:'
   325                      ' {0}'.format(key))
   326          for i in range(len(self.ssh_keys)):
   327              if self.ssh_keys[i] in keys_to_remove:
   328                  keys_to_remove.remove(self.ssh_keys[i])
   329                  del self.ssh_keys[i]
   330          errors.extend(
   331              'cannot remove key id "{0}": invalid ssh key: {0}'.format(key)
   332              for key in keys_to_remove)
   333          return '\n'.join(errors)
   334  
   335      def import_ssh_key(self, names_to_add):
   336          for name in names_to_add:
   337              self.ssh_keys.append('ssh-rsa FAKE_KEY a key {}'.format(name))
   338          return ""
   339  
   340  
   341  class FakeExpectChild:
   342  
   343      def __init__(self, backend, juju_home, extra_env):
   344          self.backend = backend
   345          self.juju_home = juju_home
   346          self.extra_env = extra_env
   347          self.last_expect = None
   348          self.exitstatus = None
   349          self.match = None
   350  
   351      def expect(self, line):
   352          self.last_expect = line
   353  
   354      def sendline(self, line):
   355          """Do-nothing implementation of sendline.
   356  
   357          Subclassess will likely override this.
   358          """
   359  
   360      def close(self):
   361          self.exitstatus = 0
   362  
   363      def isalive(self):
   364          return bool(self.exitstatus is not None)
   365  
   366  
   367  class AutoloadCredentials(FakeExpectChild):
   368  
   369      def __init__(self, backend, juju_home, extra_env):
   370          super(AutoloadCredentials, self).__init__(backend, juju_home,
   371                                                    extra_env)
   372          self.cloud = None
   373  
   374      def sendline(self, line):
   375          if self.last_expect == (
   376                  '(Select the cloud it belongs to|'
   377                  'Enter cloud to which the credential).* Q to quit.*'):
   378              self.cloud = line
   379  
   380      def isalive(self):
   381          juju_data = JujuData('foo', juju_home=self.juju_home)
   382          juju_data.load_yaml()
   383          creds = juju_data.credentials.setdefault('credentials', {})
   384          creds.update({self.cloud: {
   385              'default-region': self.extra_env['OS_REGION_NAME'],
   386              self.extra_env['OS_USERNAME']: {
   387                  'domain-name': '',
   388                  'user-domain-name': '',
   389                  'project-domain-name': '',
   390                  'auth-type': 'userpass',
   391                  'username': self.extra_env['OS_USERNAME'],
   392                  'password': self.extra_env['OS_PASSWORD'],
   393                  'tenant-name': self.extra_env['OS_TENANT_NAME'],
   394                  }}})
   395          juju_data.dump_yaml(self.juju_home)
   396          return False
   397  
   398      def eof(self):
   399          return False
   400  
   401      def readline(self):
   402          return (' 1. openstack region "region" project '
   403                  '"openstack-credentials-0" user "testing-user" (new) '
   404                  ' 2. openstack region "region" project '
   405                  '"openstack-credentials-1" user "testing-user" (new) '
   406                  ' 3. openstack region "region" project '
   407                  '"openstack-credentials-2" user "testing-user" (new) ')
   408  
   409  
   410  class PromptingExpectChild(FakeExpectChild):
   411      """A fake ExpectChild based on prompt/response.
   412  
   413      It accepts an iterator of prompts.  If that iterator supports send(),
   414      the last input to sendline will be sent.
   415  
   416      This allows fairly natural generators, e.g.:
   417  
   418          foo = yield "Please give me foo".
   419  
   420      You can also just iterate through prompts and retrieve the corresponding
   421      values from self.values at the end.
   422      """
   423  
   424      def __init__(self, backend, juju_home, extra_env, prompts):
   425          super(PromptingExpectChild, self).__init__(backend, juju_home,
   426                                                     extra_env)
   427          self._prompts = iter(prompts)
   428          self.values = {}
   429          self.lines = []
   430          # If not a generator, invoke next() instead of send.
   431          self._send = getattr(self._prompts, 'send',
   432                               lambda x: next(self._prompts))
   433          self._send_line = None
   434  
   435      @property
   436      def prompts(self):
   437          return self._prompts
   438  
   439      def expect(self, pattern):
   440          if type(pattern) is not list:
   441              pattern = [pattern]
   442          try:
   443              prompt = self._send(self._send_line)
   444              self._send_line = None
   445          except StopIteration:
   446              if pexpect.EOF not in pattern:
   447                  raise
   448              self.close()
   449              return
   450          for regex in pattern:
   451              if regex is pexpect.EOF:
   452                  continue
   453              regex_match = re.search(regex, prompt)
   454              if regex_match is not None:
   455                  self.match = regex_match
   456                  break
   457          else:
   458              if pexpect.EOF in pattern:
   459                  raise ValueError('Expected EOF. got "{}"'.format(prompt))
   460              else:
   461                  raise ValueError(
   462                      'Regular expression did not match prompt.  Regex: "{}",'
   463                      ' prompt "{}"'.format(pattern, prompt))
   464          super(PromptingExpectChild, self).expect(regex)
   465  
   466      def sendline(self, line=''):
   467          if self._send_line is not None:
   468              raise ValueError('Sendline called twice with no expect.')
   469          full_match = self.match.group(0)
   470          self.values[full_match] = line.rstrip()
   471          self.lines.append((full_match, line))
   472          self._send_line = line
   473  
   474  
   475  class LoginUser(PromptingExpectChild):
   476  
   477      def __init__(self, backend, juju_home, extra_env, username):
   478          self.username = username
   479          super(LoginUser, self).__init__(backend, juju_home, extra_env, [
   480              'Password:',
   481          ])
   482  
   483      def close(self):
   484          self.backend.controller_state.login_user(
   485              self.username,
   486              self.values['Password'],
   487              )
   488          super(LoginUser, self).close()
   489  
   490  
   491  class RegisterHost(PromptingExpectChild):
   492  
   493      def __init__(self, backend, juju_home, extra_env):
   494          super(RegisterHost, self).__init__(backend, juju_home, extra_env, [
   495              'E-Mail:',
   496              'Password:',
   497              'Two-factor auth (Enter for none):',
   498              'Enter a name for this controller:',
   499          ])
   500  
   501      def close(self):
   502          self.backend.controller_state.register(
   503              self.values['Enter a name for this controller:'],
   504              self.values['E-Mail:'],
   505              self.values['Password:'],
   506              self.values['Two-factor auth (Enter for none):'],
   507              )
   508          super(RegisterHost, self).close()
   509  
   510  
   511  class AddCloud(PromptingExpectChild):
   512  
   513      @property
   514      def provider(self):
   515          return self.values[self.TYPE]
   516  
   517      @property
   518      def name_prompt(self):
   519          return 'Enter a name for your {} cloud:'.format(self.provider)
   520  
   521      REGION_NAME = 'Enter region name:'
   522  
   523      TYPE = 'Select cloud type:'
   524  
   525      AUTH = 'Select one or more auth types separated by commas:'
   526  
   527      API_ENDPOINT = 'Enter the API endpoint url:'
   528  
   529      CLOUD_ENDPOINT = 'Enter the API endpoint url for the cloud:'
   530  
   531      REGION_ENDPOINT = (
   532          'Enter the API endpoint url for the region [use cloud api url]:')
   533  
   534      HOST = "Enter the controller's hostname or IP address:"
   535  
   536      ANOTHER_REGION = 'Enter another region? (Y/n):'
   537  
   538      VCENTER_ADDRESS = "Enter the vCenter address or URL:"
   539  
   540      DATACENTER_NAME = "Enter datacenter name:"
   541  
   542      ANOTHER_DATACENTER = 'Enter another datacenter? (Y/n):'
   543  
   544      def cant_validate(self, endpoint):
   545          if self.provider in ('openstack', 'maas'):
   546              if self.provider == 'openstack':
   547                  server_type = 'Openstack'
   548                  reprompt = self.CLOUD_ENDPOINT
   549              else:
   550                  server_type = 'MAAS'
   551                  reprompt = self.API_ENDPOINT
   552              msg = 'No {} server running at {}'.format(server_type, endpoint)
   553          elif self.provider == 'manual':
   554              msg = 'ssh: Could not resolve hostname {}'.format(endpoint)
   555              reprompt = self.HOST
   556          elif self.provider == 'vsphere':
   557              msg = '{}: invalid domain name'.format(endpoint)
   558              reprompt = self.VCENTER_ADDRESS
   559          return "Can't validate endpoint: {}\n{}".format(
   560              msg, reprompt)
   561  
   562      def __init__(self, backend, juju_home, extra_env):
   563          super(AddCloud, self).__init__(
   564              backend, juju_home, extra_env, self.iter_prompts())
   565  
   566      def iter_prompts(self):
   567          while True:
   568              provider_type = yield self.TYPE
   569              if provider_type != 'bogus':
   570                  break
   571          while True:
   572              name = yield self.name_prompt
   573              if '/' not in name:
   574                  break
   575          if provider_type == 'maas':
   576              endpoint = yield self.API_ENDPOINT
   577              while len(endpoint) > 1000:
   578                  yield self.cant_validate(endpoint)
   579          elif provider_type == 'manual':
   580              endpoint = yield self.HOST
   581              while len(endpoint) > 1000:
   582                  yield self.cant_validate(endpoint)
   583          elif provider_type == 'openstack':
   584              endpoint = yield self.CLOUD_ENDPOINT
   585              while len(endpoint) > 1000:
   586                  yield self.cant_validate(endpoint)
   587              while True:
   588                  auth = yield self.AUTH
   589                  if 'invalid' not in auth:
   590                      break
   591              while True:
   592                  yield self.REGION_NAME
   593                  endpoint = yield self.REGION_ENDPOINT
   594                  if len(endpoint) > 1000:
   595                      yield self.cant_validate(endpoint)
   596                  if (yield self.ANOTHER_REGION) == 'n':
   597                      break
   598          elif provider_type == 'vsphere':
   599              endpoint = yield self.VCENTER_ADDRESS
   600              if len(endpoint) > 1000:
   601                  yield self.cant_validate(endpoint)
   602              while True:
   603                  yield self.DATACENTER_NAME
   604                  if (yield self.ANOTHER_DATACENTER) == 'n':
   605                      break
   606  
   607      def close(self):
   608          cloud = {
   609              'type': self.values[self.TYPE],
   610          }
   611          if cloud['type'] == 'maas':
   612              cloud.update({'endpoint': self.values[self.API_ENDPOINT]})
   613          if cloud['type'] == 'manual':
   614              cloud.update({'endpoint': self.values[self.HOST]})
   615          if cloud['type'] == 'openstack':
   616              regions = {}
   617              for match, line in self.lines:
   618                  if match == self.REGION_NAME:
   619                      cur_region = {}
   620                      regions[line] = cur_region
   621                  if match == self.REGION_ENDPOINT:
   622                      cur_region['endpoint'] = line
   623              cloud.update({
   624                  'endpoint': self.values[self.CLOUD_ENDPOINT],
   625                  'auth-types': self.values[self.AUTH].split(','),
   626                  'regions': regions
   627                  })
   628          if cloud['type'] == 'vsphere':
   629              regions = {}
   630              for match, line in self.lines:
   631                  if match == self.DATACENTER_NAME:
   632                      cur_region = {}
   633                      regions[line] = cur_region
   634              cloud.update({
   635                  'endpoint': self.values[self.VCENTER_ADDRESS],
   636                  'regions': regions,
   637                  })
   638          self.backend.clouds[self.values[self.name_prompt]] = cloud
   639  
   640  
   641  class AddCloud2_1(AddCloud):
   642  
   643      REGION_ENDPOINT = 'Enter the API endpoint url for the region:'
   644  
   645      VCENTER_ADDRESS = AddCloud.CLOUD_ENDPOINT
   646  
   647      DATACENTER_NAME = AddCloud.REGION_NAME
   648  
   649      ANOTHER_DATACENTER = AddCloud.ANOTHER_REGION
   650  
   651  
   652  class FakeBackend:
   653      """A fake juju backend for tests.
   654  
   655      This is a partial implementation, but should be suitable for many uses,
   656      and can be extended.
   657  
   658      The state is provided by controller_state, so that multiple clients and
   659      backends can manipulate the same state.
   660      """
   661  
   662      def __init__(self, controller_state, feature_flags=None, version=None,
   663                   full_path=None, debug=False, past_deadline=False):
   664          assert isinstance(controller_state, FakeControllerState)
   665          self.controller_state = controller_state
   666          if feature_flags is None:
   667              feature_flags = set()
   668          self.feature_flags = feature_flags
   669          self.version = version
   670          self.full_path = full_path
   671          self.debug = debug
   672          self.juju_timings = {}
   673          self.log = logging.getLogger('jujupy')
   674          self._past_deadline = past_deadline
   675          self._ignore_soft_deadline = False
   676          self.clouds = {}
   677          self.action_results = {}
   678          self.action_queue = {}
   679          self.added_models = []
   680  
   681      def track_model(self, client):
   682          pass
   683  
   684      def untrack_model(self, client):
   685          pass
   686  
   687      def clone(self, full_path=None, version=None, debug=None,
   688                feature_flags=None):
   689          if version is None:
   690              version = self.version
   691          if full_path is None:
   692              full_path = self.full_path
   693          if debug is None:
   694              debug = self.debug
   695          if feature_flags is None:
   696              feature_flags = set(self.feature_flags)
   697          controller_state = self.controller_state
   698          return self.__class__(controller_state, feature_flags, version,
   699                                full_path, debug,
   700                                past_deadline=self._past_deadline)
   701  
   702      def is_feature_enabled(self, feature):
   703          return bool(feature in self.feature_flags)
   704  
   705      @contextmanager
   706      def ignore_soft_deadline(self):
   707          """Ignore the client deadline.  For cleanup code."""
   708          old_val = self._ignore_soft_deadline
   709          self._ignore_soft_deadline = True
   710          try:
   711              yield
   712          finally:
   713              self._ignore_soft_deadline = old_val
   714  
   715      @contextmanager
   716      def _check_timeouts(self):
   717          try:
   718              yield
   719          finally:
   720              if self._past_deadline and not self._ignore_soft_deadline:
   721                  raise SoftDeadlineExceeded()
   722  
   723      def get_active_model(self, juju_home):
   724          return self.controller_state.active_model
   725  
   726      def get_active_controller(self, juju_home):
   727          return self.controller_state.name
   728  
   729      def deploy(self, model_state, charm_name, num, service_name=None,
   730                 series=None):
   731          if service_name is None:
   732              service_name = charm_name.split(':')[-1].split('/')[-1]
   733          for i in range(num):
   734              model_state.deploy(charm_name, service_name)
   735  
   736      def bootstrap(self, args):
   737          parser = ArgumentParser()
   738          parser.add_argument('cloud_name_region')
   739          parser.add_argument('controller_name')
   740          parser.add_argument('--constraints')
   741          parser.add_argument('--config')
   742          parser.add_argument('--default-model')
   743          parser.add_argument('--agent-version')
   744          parser.add_argument('--bootstrap-series')
   745          parser.add_argument('--upload-tools', action='store_true')
   746          parsed = parser.parse_args(args)
   747          with open(parsed.config) as config_file:
   748              config = yaml.safe_load(config_file)
   749          cloud_region = parsed.cloud_name_region.split('/', 1)
   750          cloud = cloud_region[0]
   751          # Although they are specified with specific arguments instead of as
   752          # config, these values are listed by model-config:
   753          # name, region, type (from cloud).
   754          config['type'] = cloud
   755          if len(cloud_region) > 1:
   756              config['region'] = cloud_region[1]
   757          config['name'] = parsed.default_model
   758          if parsed.bootstrap_series is not None:
   759              config['default-series'] = parsed.bootstrap_series
   760          self.controller_state.bootstrap(parsed.default_model, config)
   761  
   762      def quickstart(self, model_name, config, bundle):
   763          default_model = self.controller_state.bootstrap(model_name, config)
   764          default_model.deploy_bundle(bundle)
   765  
   766      def add_machines(self, model_state, args):
   767          if len(args) == 0:
   768              return model_state.add_machine()
   769          ssh_machines = [a[4:] for a in args if a.startswith('ssh:')]
   770          if len(ssh_machines) == len(args):
   771              return model_state.add_ssh_machines(ssh_machines)
   772          parser = ArgumentParser()
   773          parser.add_argument('host_placement', nargs='*')
   774          parser.add_argument('-n', type=int, dest='count', default='1')
   775          parser.add_argument('--series')
   776          parsed = parser.parse_args(args)
   777          if len(parsed.host_placement) > 0 and parsed.count != 1:
   778                  raise subprocess.CalledProcessError(
   779                      1, 'cannot use -n when specifying a placement directive.'
   780                      'See Lp #1384350.')
   781          if len(parsed.host_placement) == 1:
   782              split = parsed.host_placement[0].split(':')
   783              if len(split) == 1:
   784                  container_type = split[0]
   785                  host = None
   786              else:
   787                  container_type, host = split
   788              for x in range(parsed.count):
   789                  model_state.add_container(container_type, host=host)
   790          else:
   791              for x in range(parsed.count):
   792                  model_state.add_machine()
   793  
   794      def get_controller_model_name(self):
   795          return self.controller_state.controller_model.name
   796  
   797      def make_controller_dict(self, controller_name):
   798          controller_model = self.controller_state.controller_model
   799          server_id = list(controller_model.state_servers)[0]
   800          server_hostname = controller_model.machine_host_names[server_id]
   801          api_endpoint = '{}:23'.format(server_hostname)
   802          uuid = 'b74b0e9a-81cb-4161-8396-bd5149e2a3cc'
   803          return {
   804              controller_name: {
   805                  'details': {
   806                      'api-endpoints': [api_endpoint],
   807                      'uuid': uuid,
   808                  }
   809              }
   810          }
   811  
   812      def list_models(self):
   813          model_names = [state.name for state in
   814                         self.controller_state.models.values()]
   815          return {'models': [{'name': n} for n in model_names]}
   816  
   817      def list_users(self):
   818          user_names = [name for name in
   819                        self.controller_state.users.keys()]
   820          user_list = []
   821          for n in user_names:
   822              if n == 'admin':
   823                  append_dict = {'access': 'superuser', 'user-name': n,
   824                                 'display-name': n}
   825              else:
   826                  access = self.controller_state.users[n]['access']
   827                  append_dict = {
   828                      'access': access, 'user-name': n}
   829              user_list.append(append_dict)
   830          return user_list
   831  
   832      def show_user(self, user_name):
   833          if user_name is None:
   834              raise Exception("No user specified")
   835          if user_name == 'admin':
   836              user_status = {'access': 'superuser', 'user-name': user_name,
   837                             'display-name': user_name}
   838          else:
   839              user_status = {'user-name': user_name, 'display-name': ''}
   840          return user_status
   841  
   842      def get_users(self):
   843          share_names = self.controller_state.shares
   844          permissions = []
   845          for key, value in self.controller_state.users.iteritems():
   846              if key in share_names:
   847                  permissions.append(value['permission'])
   848          share_list = {}
   849          for i, (share_name, permission) in enumerate(
   850                  zip(share_names, permissions)):
   851              share_list[share_name] = {'display-name': share_name,
   852                                        'access': permission}
   853              if share_name != 'admin':
   854                  share_list[share_name].pop('display-name')
   855              else:
   856                  share_list[share_name]['access'] = 'admin'
   857          return share_list
   858  
   859      def show_model(self):
   860          # To get data from the model we would need:
   861          # self.controller_state.current_model
   862          model_name = 'name'
   863          data = {
   864              'name': model_name,
   865              'owner': 'admin',
   866              'life': 'alive',
   867              'status': {'current': 'available', 'since': '15 minutes ago'},
   868              'users': self.get_users(),
   869              }
   870          return {model_name: data}
   871  
   872      def run_action(self, unit_id, action):
   873          action_uuid = '{}'.format(uuid.uuid1())
   874          try:
   875              result = self.action_results[unit_id][action]
   876              self.action_queue[action_uuid] = result
   877          except KeyError:
   878              raise ValueError('No such action "{0}"'
   879                               ' specified for unit {1}.'.format(action,
   880                                                                 unit_id))
   881          return ('Action queued with id: {}'.format(action_uuid))
   882  
   883      def show_action_output(self, action_uuid):
   884          return self.action_queue.get(action_uuid, None)
   885  
   886      def _log_command(self, command, args, model, level=logging.INFO):
   887          full_args = ['juju', command]
   888          if model is not None:
   889              full_args.extend(['-m', model])
   890          full_args.extend(args)
   891          self.log.log(level, u' '.join(full_args))
   892  
   893      def juju(self, command, args, used_feature_flags,
   894               juju_home, model=None, check=True, timeout=None, extra_env=None,
   895               suppress_err=False):
   896          if 'service' in command:
   897              raise Exception('Command names must not contain "service".')
   898  
   899          if isinstance(args, argtype):
   900              args = (args,)
   901          self._log_command(command, args, model)
   902          if model is not None:
   903              if ':' in model:
   904                  model = model.split(':')[1]
   905              model_state = self.controller_state.models[model]
   906              if ((command, args[:1]) == ('set-config', ('dummy-source',)) or
   907                      (command, args[:1]) == ('config', ('dummy-source',))):
   908                  name, value = args[1].split('=')
   909                  if name == 'token':
   910                      model_state.token = value
   911              if command == 'deploy':
   912                  parser = ArgumentParser()
   913                  parser.add_argument('charm_name')
   914                  parser.add_argument('service_name', nargs='?')
   915                  parser.add_argument('--to')
   916                  parser.add_argument('--series')
   917                  parser.add_argument('-n')
   918                  parsed = parser.parse_args(args)
   919                  num = int(parsed.n or 1)
   920                  self.deploy(model_state, parsed.charm_name, num,
   921                              parsed.service_name, parsed.series)
   922                  return (0, CommandTime(command, args))
   923              if command == 'remove-application':
   924                  model_state.destroy_service(*args)
   925              if command == 'add-relation':
   926                  if args[0] == 'dummy-source':
   927                      model_state.relations[args[1]] = {'source': [args[0]]}
   928              if command == 'expose':
   929                  (service,) = args
   930                  model_state.exposed.add(service)
   931              if command == 'unexpose':
   932                  (service,) = args
   933                  model_state.exposed.remove(service)
   934              if command == 'add-unit':
   935                  (service,) = args
   936                  model_state.add_unit(service)
   937              if command == 'remove-unit':
   938                  (unit_id,) = args
   939                  model_state.remove_unit(unit_id)
   940              if command == 'add-machine':
   941                  return self.add_machines(model_state, args)
   942              if command == 'remove-machine':
   943                  parser = ArgumentParser()
   944                  parser.add_argument('machine_id')
   945                  parser.add_argument('--force', action='store_true')
   946                  parsed = parser.parse_args(args)
   947                  machine_id = parsed.machine_id
   948                  if '/' in machine_id:
   949                      model_state.remove_container(machine_id)
   950                  else:
   951                      model_state.remove_machine(machine_id, parsed.force)
   952              if command == 'quickstart':
   953                  parser = ArgumentParser()
   954                  parser.add_argument('--constraints')
   955                  parser.add_argument('--no-browser', action='store_true')
   956                  parser.add_argument('bundle')
   957                  parsed = parser.parse_args(args)
   958                  # Released quickstart doesn't seem to provide the config via
   959                  # the commandline.
   960                  self.quickstart(model, {}, parsed.bundle)
   961          else:
   962              if command == 'bootstrap':
   963                  self.bootstrap(args)
   964              if command == 'destroy-controller':
   965                  if self.controller_state.state not in ('bootstrapped',
   966                                                         'created'):
   967                      raise subprocess.CalledProcessError(1, 'Not bootstrapped.')
   968                  self.controller_state.destroy()
   969              if command == 'kill-controller':
   970                  if self.controller_state.state == 'not-bootstrapped':
   971                      return (0, CommandTime(command, args))
   972                  self.controller_state.destroy(kill=True)
   973                  return (0, CommandTime(command, args))
   974              if command == 'destroy-model':
   975                  model = args[0].split(':')[1]
   976                  try:
   977                      model_state = self.controller_state.models[model]
   978                  except KeyError:
   979                      raise subprocess.CalledProcessError(1, 'No such model')
   980                  model_state.destroy_model()
   981              if command == 'enable-ha':
   982                  parser = ArgumentParser()
   983                  parser.add_argument('-n', '--number')
   984                  parser.add_argument('-c', '--controller')
   985                  parsed = parser.parse_args(args)
   986                  if not self.controller_state.name == parsed.controller:
   987                      raise AssertionError('Test does not setup controller name')
   988                  model_state = self.controller_state.controller_model
   989                  model_state.enable_ha()
   990              if command == 'add-model':
   991                  parser = ArgumentParser()
   992                  parser.add_argument('-c', '--controller')
   993                  parser.add_argument('--config')
   994                  parser.add_argument('--credential')
   995                  parser.add_argument('model_name')
   996                  parser.add_argument('cloud-region', nargs='?')
   997                  parsed = parser.parse_args(args)
   998                  model_client = self.controller_state.add_model(
   999                      parsed.model_name)
  1000                  self.added_models.append(model_client)
  1001              if command == 'revoke':
  1002                  user_name = args[2]
  1003                  permissions = args[3]
  1004                  per = self.controller_state.users[user_name]['permission']
  1005                  if per == permissions:
  1006                      if permissions == 'read':
  1007                          self.controller_state.shares.remove(user_name)
  1008                          per = ''
  1009                      else:
  1010                          per = 'read'
  1011              if command == 'grant':
  1012                  username = args[0]
  1013                  permission = args[1]
  1014                  self.controller_state.grant(username, permission)
  1015              if command == 'remove-user':
  1016                  username = args[0]
  1017                  self.controller_state.users.pop(username)
  1018                  if username in self.controller_state.shares:
  1019                      self.controller_state.shares.remove(username)
  1020              if command == 'restore-backup':
  1021                  model_state.restore_backup()
  1022              return 0, CommandTime(command, args)
  1023  
  1024      @contextmanager
  1025      def juju_async(self, command, args, used_feature_flags,
  1026                     juju_home, model=None, timeout=None):
  1027          yield
  1028          self.juju(command, args, used_feature_flags,
  1029                    juju_home, model, timeout=timeout)
  1030  
  1031      def get_juju_output(self, command, args, used_feature_flags, juju_home,
  1032                          model=None, timeout=None, user_name=None,
  1033                          merge_stderr=False):
  1034          if 'service' in command:
  1035              raise Exception('No service')
  1036          with self._check_timeouts():
  1037              self._log_command(command, args, model, logging.DEBUG)
  1038              if model is not None:
  1039                  if ':' in model:
  1040                      model = model.split(':')[1]
  1041                  model_state = self.controller_state.models[model]
  1042              sink_cat = ('dummy-sink/0', 'cat', '/var/run/dummy-sink/token')
  1043              if (command, args) == ('ssh', sink_cat):
  1044                  return model_state.token
  1045              if (command, args) == ('ssh', ('0', 'lsb_release', '-c')):
  1046                  return 'Codename:\t{}\n'.format(
  1047                      model_state.model_config['default-series'])
  1048              if command in ('model-config', 'get-model-config'):
  1049                  return yaml.safe_dump(model_state.model_config)
  1050              if command == 'show-controller':
  1051                  return yaml.safe_dump(self.make_controller_dict(args[0]))
  1052              if command == 'list-models':
  1053                  return yaml.safe_dump(self.list_models())
  1054              if command == 'list-users':
  1055                  return json.dumps(self.list_users())
  1056              if command == 'show-model':
  1057                  return json.dumps(self.show_model())
  1058              if command == 'show-user':
  1059                  return json.dumps(self.show_user(user_name))
  1060              if command == 'add-user':
  1061                  permissions = 'read'
  1062                  if set(["--acl", "write"]).issubset(args):
  1063                      permissions = 'write'
  1064                  username = args[0]
  1065                  info_string = 'User "{}" added\n'.format(username)
  1066                  self.controller_state.add_user_perms(username, permissions)
  1067                  register_string = get_user_register_command_info(username)
  1068                  return info_string + register_string
  1069              if command == 'show-status':
  1070                  status_dict = model_state.get_status_dict()
  1071                  # Parsing JSON is much faster than parsing YAML, and JSON is a
  1072                  # subset of YAML, so emit JSON.
  1073                  return json.dumps(status_dict).encode('utf-8')
  1074              if command == 'create-backup':
  1075                  self.controller_state.require_controller('backup', model)
  1076                  return 'juju-backup-0.tar.gz'
  1077              if command == 'ssh-keys':
  1078                  lines = ['Keys used in model: ' + model_state.name]
  1079                  if '--full' in args:
  1080                      lines.extend(model_state.ssh_keys)
  1081                  else:
  1082                      lines.extend(':fake:fingerprint: ({})'.format(
  1083                          k.split(' ', 2)[-1]) for k in model_state.ssh_keys)
  1084                  return '\n'.join(lines)
  1085              if command == 'add-ssh-key':
  1086                  return model_state.add_ssh_key(args)
  1087              if command == 'remove-ssh-key':
  1088                  return model_state.remove_ssh_key(args)
  1089              if command == 'import-ssh-key':
  1090                  return model_state.import_ssh_key(args)
  1091              if command == 'run-action':
  1092                  unit_id = args[0]
  1093                  action = args[1]
  1094                  return self.run_action(unit_id, action)
  1095              if command == 'show-action-output':
  1096                  return self.show_action_output(args[0])
  1097              return ''
  1098  
  1099      def expect(self, command, args, used_feature_flags, juju_home, model=None,
  1100                 timeout=None, extra_env=None):
  1101          if command == 'autoload-credentials':
  1102              return AutoloadCredentials(self, juju_home, extra_env)
  1103          if command == 'register':
  1104              return RegisterHost(self, juju_home, extra_env)
  1105          if command == 'add-cloud':
  1106              return AddCloud(self, juju_home, extra_env)
  1107          if command == 'login -u':
  1108              return LoginUser(self, juju_home, extra_env, args[0])
  1109          return FakeExpectChild(self, juju_home, extra_env)
  1110  
  1111      def pause(self, seconds):
  1112          pass
  1113  
  1114  
  1115  def get_user_register_command_info(username):
  1116      code = get_user_register_token(username)
  1117      return 'Please send this command to {}\n    juju register {}'.format(
  1118          username, code)
  1119  
  1120  
  1121  def get_user_register_token(username):
  1122      return b64encode(sha512(username.encode('utf-8')).digest()).decode('ascii')
  1123  
  1124  
  1125  def fake_juju_client(env=None, full_path=None, debug=False, version='2.0.0',
  1126                       _backend=None, cls=ModelClient, juju_home=None):
  1127      if juju_home is None:
  1128          if env is None or env.juju_home is None:
  1129              juju_home = 'foo'
  1130          else:
  1131              juju_home = env.juju_home
  1132      if env is None:
  1133          env = JujuData('name', {
  1134              'type': 'foo',
  1135              'default-series': 'angsty',
  1136              'region': 'bar',
  1137              }, juju_home=juju_home)
  1138          env.credentials = {'credentials': {'foo': {'creds': {}}}}
  1139      if _backend is None:
  1140          backend_state = FakeControllerState()
  1141          _backend = FakeBackend(
  1142              backend_state, version=version, full_path=full_path,
  1143              debug=debug)
  1144      client = cls(
  1145          env, version, full_path, juju_home, debug, _backend=_backend)
  1146      client.bootstrap_replaces = {}
  1147      return client