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