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

     1  #!/usr/bin/env python
     2  """Tests for the Model Migration feature"""
     3  
     4  from __future__ import print_function
     5  
     6  import argparse
     7  from contextlib import contextmanager
     8  from distutils.version import (
     9      LooseVersion,
    10      StrictVersion
    11      )
    12  import logging
    13  import os
    14  from subprocess import CalledProcessError
    15  import sys
    16  from time import sleep
    17  import yaml
    18  
    19  from assess_user_grant_revoke import User
    20  from deploy_stack import (
    21      BootstrapManager,
    22      get_random_string
    23      )
    24  from jujupy.client import (
    25      get_stripped_version_number,
    26  )
    27  from jujupy.wait_condition import (
    28      ModelCheckFailed,
    29      wait_for_model_check,
    30  )
    31  from jujupy.workloads import (
    32      assert_deployed_charm_is_responding,
    33      deploy_dummy_source_to_new_model,
    34      deploy_simple_server_to_new_model,
    35      )
    36  from remote import remote_from_address
    37  from utility import (
    38      JujuAssertionError,
    39      add_basic_testing_arguments,
    40      configure_logging,
    41      qualified_model_name,
    42      temp_dir,
    43      until_timeout,
    44      )
    45  
    46  
    47  __metaclass__ = type
    48  
    49  
    50  log = logging.getLogger("assess_model_migration")
    51  
    52  
    53  def assess_model_migration(bs1, bs2, args):
    54      with bs1.booted_context(args.upload_tools):
    55          bs1.client.enable_feature('migration')
    56          bs2.client.enable_feature('migration')
    57          bs2.client.env.juju_home = bs1.client.env.juju_home
    58          with bs2.existing_booted_context(args.upload_tools):
    59              source_client = bs2.client
    60              dest_client = bs1.client
    61              # Capture the migrated client so we can use it to assert it
    62              # continues to operate after the originating controller is torn
    63              # down.
    64              results = ensure_migration_with_resources_succeeds(
    65                  source_client,
    66                  dest_client)
    67              migrated_client, application, resource_contents = results
    68  
    69              ensure_model_logs_are_migrated(source_client, dest_client)
    70              assess_user_permission_model_migrations(source_client, dest_client)
    71              if args.use_develop:
    72                  assess_development_branch_migrations(
    73                      source_client, dest_client)
    74  
    75          # Continue test where we ensure that a migrated model continues to
    76          # work after it's originating controller has been destroyed.
    77          assert_model_migrated_successfully(
    78              migrated_client, application, resource_contents)
    79          log.info(
    80              'SUCCESS: Model operational after origin controller destroyed')
    81  
    82  
    83  def assess_user_permission_model_migrations(source_client, dest_client):
    84      """Run migration tests for user permissions."""
    85      with temp_dir() as temp:
    86          ensure_migrating_with_insufficient_user_permissions_fails(
    87              source_client, dest_client, temp)
    88          ensure_migrating_with_superuser_user_permissions_succeeds(
    89              source_client, dest_client, temp)
    90  
    91  
    92  def assess_development_branch_migrations(source_client, dest_client):
    93      with temp_dir() as temp:
    94          ensure_superuser_can_migrate_other_user_models(
    95                  source_client, dest_client, temp)
    96      ensure_migration_rolls_back_on_failure(source_client, dest_client)
    97      ensure_api_login_redirects(source_client, dest_client)
    98  
    99  
   100  def after_22beta4(client_version):
   101      # LooseVersion considers 2.2-somealpha to be newer than 2.2.0.
   102      # Attempt strict versioning first.
   103      client_version = get_stripped_version_number(client_version)
   104      try:
   105          return StrictVersion(client_version) >= StrictVersion('2.2.0')
   106      except ValueError:
   107          pass
   108      return LooseVersion(client_version) >= LooseVersion('2.2-beta4')
   109  
   110  
   111  def parse_args(argv):
   112      """Parse all arguments."""
   113      parser = argparse.ArgumentParser(
   114          description="Test model migration feature")
   115      add_basic_testing_arguments(parser, existing=False)
   116      parser.add_argument(
   117          '--use-develop',
   118          action='store_true',
   119          help='Run tests that rely on features in the develop branch.')
   120      return parser.parse_args(argv)
   121  
   122  
   123  def get_bootstrap_managers(args):
   124      """Create 2 bootstrap managers from the provided args.
   125  
   126      Need to make a couple of elements unique (e.g. environment name) so we can
   127      have 2 bootstrapped at the same time.
   128      """
   129      bs_1 = BootstrapManager.from_args(args)
   130      bs_2 = BootstrapManager.from_args(args)
   131      # Give the second a separate/unique name.
   132      bs_2.temp_env_name = '{}-b'.format(bs_1.temp_env_name)
   133      bs_1.log_dir = _new_log_dir(args.logs, 'a')
   134      bs_2.log_dir = _new_log_dir(args.logs, 'b')
   135      return bs_1, bs_2
   136  
   137  
   138  def _new_log_dir(log_dir, post_fix):
   139      new_log_dir = os.path.join(log_dir, 'env-{}'.format(post_fix))
   140      os.mkdir(new_log_dir)
   141      return new_log_dir
   142  
   143  
   144  def wait_until_model_disappears(client, model_name, timeout=120):
   145      """Waits for a while for 'model_name' model to no longer be listed.
   146  
   147      :raises JujuAssertionError: If the named model continues to be listed in
   148        list-models after specified timeout.
   149      """
   150      def model_check(client):
   151          try:
   152              models = client.get_controller_client().get_models()
   153          except CalledProcessError as e:
   154              # It's possible that we've tried to get status from the model as
   155              # it's being removed.
   156              # We can't consider the model gone yet until we don't get this
   157              # error and the model is no longer in the output.
   158              if 'cannot get model details' not in e.stderr:
   159                  raise
   160          else:
   161              # 2.2-rc1 introduced new model listing output name/short-name.
   162              all_model_names = [
   163                  m.get('short-name', m['name']) for m in models['models']]
   164              if model_name not in all_model_names:
   165                  return True
   166  
   167      try:
   168          wait_for_model_check(client, model_check, timeout)
   169      except ModelCheckFailed:
   170          raise JujuAssertionError(
   171              'Model "{}" failed to be removed after {} seconds'.format(
   172                  model_name, timeout))
   173  
   174  
   175  def wait_for_model(client, model_name, timeout=120):
   176      """Wait for a given timeout for the client to see the model_name.
   177  
   178      :raises JujuAssertionError: If the named model does not appear in the
   179        specified timeout.
   180      """
   181      def model_check(client):
   182          models = client.get_controller_client().get_models()
   183          # 2.2-rc1 introduced new model listing output name/short-name.
   184          all_model_names = [
   185              m.get('short-name', m['name']) for m in models['models']]
   186          if model_name in all_model_names:
   187              return True
   188      try:
   189          wait_for_model_check(client, model_check, timeout)
   190      except ModelCheckFailed:
   191          raise JujuAssertionError(
   192              'Model "{}" failed to appear after {} seconds'.format(
   193                  model_name, timeout))
   194  
   195  
   196  def wait_for_migrating(client, timeout=120):
   197      """Block until provided model client has a migration status.
   198  
   199      :raises JujuAssertionError: If the status doesn't show migration within the
   200          `timeout` period.
   201      """
   202      model_name = client.env.environment
   203      with client.check_timeouts():
   204          with client.ignore_soft_deadline():
   205              for _ in until_timeout(timeout):
   206                  model_details = client.show_model(model_name)
   207                  migration_status = model_details[model_name]['status'].get(
   208                      'migration')
   209                  if migration_status is not None:
   210                      return
   211                  sleep(1)
   212              raise JujuAssertionError(
   213                  'Model \'{}\' failed to start migration after'
   214                  '{} seconds'.format(
   215                      model_name, timeout
   216                  ))
   217  
   218  
   219  def ensure_api_login_redirects(source_client, dest_client):
   220      """Login attempts must get transparently redirected to the new controller.
   221      """
   222      new_model_client = deploy_dummy_source_to_new_model(
   223          source_client, 'api-redirection')
   224  
   225      # show model controller details
   226      before_model_details = source_client.show_model()
   227      assert_model_has_correct_controller_uuid(source_client)
   228  
   229      log.info('Attempting migration process')
   230  
   231      migrated_model_client = migrate_model_to_controller(
   232          new_model_client, dest_client)
   233  
   234      # check show model controller details
   235      assert_model_has_correct_controller_uuid(migrated_model_client)
   236  
   237      after_migration_details = migrated_model_client.show_model()
   238      before_controller_uuid = before_model_details[
   239          source_client.env.environment]['controller-uuid']
   240      after_controller_uuid = after_migration_details[
   241          migrated_model_client.env.environment]['controller-uuid']
   242      if before_controller_uuid == after_controller_uuid:
   243          raise JujuAssertionError()
   244  
   245      # Check file for details.
   246      assert_data_file_lists_correct_controller_for_model(
   247          migrated_model_client,
   248          expected_controller=dest_client.env.controller.name)
   249  
   250  
   251  def assert_data_file_lists_correct_controller_for_model(
   252          client, expected_controller):
   253      models_path = os.path.join(client.env.juju_home, 'models.yaml')
   254      with open(models_path, 'rt') as f:
   255          models_data = yaml.safe_load(f)
   256  
   257      controller_models = models_data[
   258          'controllers'][expected_controller]['models']
   259  
   260      if client.env.environment not in controller_models:
   261          raise JujuAssertionError()
   262  
   263  
   264  def assert_model_has_correct_controller_uuid(client):
   265      model_details = client.show_model()
   266      model_controller_uuid = model_details[
   267          client.env.environment]['controller-uuid']
   268      controller_uuid = client.get_controller_uuid()
   269      if model_controller_uuid != controller_uuid:
   270          raise JujuAssertionError()
   271  
   272  
   273  def ensure_migration_with_resources_succeeds(source_client, dest_client):
   274      """Test simple migration of a model to another controller.
   275  
   276      Ensure that migration a model that has an application, that uses resources,
   277      deployed upon it is able to continue it's operation after the migration
   278      process. This includes assertion that the resources are migrated correctly
   279      too.
   280  
   281      Given 2 bootstrapped environments:
   282        - Deploy an application with a resource
   283          - ensure it's operating as expected
   284        - Migrate that model to the other environment
   285          - Ensure it's operating as expected
   286          - Add a new unit to the application to ensure the model is functional
   287  
   288      :return: Tuple containing migrated client object and the resource string
   289        that the charm deployed to it outputs.
   290  
   291      """
   292      resource_contents = get_random_string()
   293      test_model, application = deploy_simple_server_to_new_model(
   294          source_client, 'example-model-resource', resource_contents)
   295      migration_target_client = migrate_model_to_controller(
   296          test_model, dest_client)
   297      assert_model_migrated_successfully(
   298          migration_target_client, application, resource_contents)
   299  
   300      log.info('SUCCESS: resources migrated')
   301      return migration_target_client, application, resource_contents
   302  
   303  
   304  def assert_model_migrated_successfully(
   305          client, application, resource_contents=None):
   306      client.wait_for_workloads()
   307      assert_deployed_charm_is_responding(client, resource_contents)
   308      ensure_model_is_functional(client, application)
   309  
   310  
   311  def ensure_superuser_can_migrate_other_user_models(
   312          source_client, dest_client, tmp_dir):
   313  
   314      norm_source_client, norm_dest_client = create_user_on_controllers(
   315          source_client, dest_client, tmp_dir, 'normaluser', 'add-model')
   316  
   317      attempt_client = deploy_dummy_source_to_new_model(
   318          norm_source_client, 'supernormal-test')
   319  
   320      log.info('Showing all models available.')
   321      source_client.controller_juju('models', ('--all',))
   322  
   323      user_qualified_model_name = qualified_model_name(
   324          attempt_client.env.environment,
   325          attempt_client.env.user_name)
   326  
   327      if after_22beta4(source_client.version):
   328          source_client.juju(
   329              'migrate',
   330              (user_qualified_model_name, dest_client.env.controller.name),
   331              include_e=False)
   332      else:
   333          source_client.controller_juju(
   334              'migrate',
   335              (user_qualified_model_name, dest_client.env.controller.name))
   336  
   337      migration_client = dest_client.clone(
   338          dest_client.env.clone(user_qualified_model_name))
   339      wait_for_model(
   340          migration_client, user_qualified_model_name)
   341      migration_client.wait_for_started()
   342      wait_until_model_disappears(source_client, user_qualified_model_name)
   343  
   344  
   345  def migrate_model_to_controller(
   346          source_client, dest_client, include_user_name=False):
   347      log.info('Initiating migration process')
   348      model_name = get_full_model_name(source_client, include_user_name)
   349  
   350      if after_22beta4(source_client.version):
   351          source_client.juju(
   352              'migrate',
   353              (model_name, dest_client.env.controller.name),
   354              include_e=False)
   355      else:
   356          source_client.controller_juju(
   357              'migrate', (model_name, dest_client.env.controller.name))
   358      migration_target_client = dest_client.clone(
   359          dest_client.env.clone(source_client.env.environment))
   360  
   361      try:
   362          wait_for_model(migration_target_client, source_client.env.environment)
   363          migration_target_client.wait_for_started()
   364          wait_until_model_disappears(
   365              source_client, source_client.env.environment, timeout=480)
   366      except JujuAssertionError as e:
   367          # Attempt to show model details as it might log migration failure
   368          # message.
   369          log.error(
   370              'Model failed to migrate. '
   371              'Attempting show-model for affected models.')
   372          try:
   373              source_client.juju('show-model', (model_name), include_e=False)
   374          except:
   375              log.info('Ignoring failed output.')
   376              pass
   377  
   378          try:
   379              source_client.juju(
   380                  'show-model',
   381                  get_full_model_name(
   382                      migration_target_client, include_user_name),
   383                  include_e=False)
   384          except:
   385              log.info('Ignoring failed output.')
   386              pass
   387          raise e
   388      return migration_target_client
   389  
   390  
   391  def get_full_model_name(client, include_user_name):
   392      # Construct model name based on rules of version + if username is needed.
   393      if include_user_name:
   394          if after_22beta4(client.version):
   395              return '{}:{}/{}'.format(
   396                  client.env.controller.name,
   397                  client.env.user_name,
   398                  client.env.environment)
   399          else:
   400              return '{}/{}'.format(
   401                  client.env.user_name, client.env.environment)
   402      else:
   403          if after_22beta4(client.version):
   404              return '{}:{}'.format(
   405                  client.env.controller.name,
   406                  client.env.environment)
   407          else:
   408              return client.env.environment
   409  
   410  
   411  def ensure_model_is_functional(client, application):
   412      """Ensures that the migrated model is functional
   413  
   414      Add unit to application to ensure the model is contactable and working.
   415      Ensure that added unit is created on a new machine (check for bug
   416      LP:1607599)
   417      """
   418      # Ensure model returns status before adding units
   419      client.get_status()
   420      client.juju('add-unit', (application,))
   421      client.wait_for_started()
   422      assert_units_on_different_machines(client, application)
   423      log.info('SUCCESS: migrated model is functional.')
   424  
   425  
   426  def assert_units_on_different_machines(client, application):
   427      status = client.get_status()
   428      # Not all units will be machines (as we have subordinate apps.)
   429      unit_machines = [
   430          u[1]['machine'] for u in status.iter_units()
   431          if u[1].get('machine', None)]
   432      raise_if_shared_machines(unit_machines)
   433  
   434  
   435  def raise_if_shared_machines(unit_machines):
   436      """Raise an exception if `unit_machines` contain double ups of machine ids.
   437  
   438      A unique list of machine ids will be equal in length to the set of those
   439      machine ids.
   440  
   441      :raises ValueError: if an empty list is passed in.
   442      :raises JujuAssertionError: if any double-ups of machine ids are detected.
   443      """
   444      if not unit_machines:
   445          raise ValueError('Cannot share 0 machines. Empty list provided.')
   446      if len(unit_machines) != len(set(unit_machines)):
   447          raise JujuAssertionError('Appliction units reside on the same machine')
   448  
   449  
   450  def ensure_model_logs_are_migrated(source_client, dest_client, timeout=120):
   451      """Ensure logs are migrated when a model is migrated between controllers.
   452  
   453      :param source_client: ModelClient representing source controller to create
   454        model on and migrate that model from.
   455      :param dest_client: ModelClient for destination controller to migrate to.
   456      :param timeout: int seconds to wait for logs to appear in migrated model.
   457      """
   458      new_model_client = deploy_dummy_source_to_new_model(
   459          source_client, 'log-migration')
   460      before_migration_logs = new_model_client.get_juju_output(
   461          'debug-log', '--no-tail', '-l', 'DEBUG')
   462      log.info('Attempting migration process')
   463      migrated_model = migrate_model_to_controller(new_model_client, dest_client)
   464  
   465      assert_logs_appear_in_client_model(
   466          migrated_model, before_migration_logs, timeout)
   467  
   468  
   469  def assert_logs_appear_in_client_model(client, expected_logs, timeout):
   470      """Assert that `expected_logs` appear in client logs within timeout.
   471  
   472      :param client: ModelClient object to query logs of.
   473      :param expected_logs: string containing log contents to check for.
   474      :param timeout: int seconds to wait for before raising JujuAssertionError.
   475      """
   476      for _ in until_timeout(timeout):
   477          current_logs = client.get_juju_output(
   478              'debug-log', '--no-tail', '--replay', '-l', 'DEBUG')
   479          if expected_logs in current_logs:
   480              log.info('SUCCESS: logs migrated.')
   481              return
   482          sleep(1)
   483      raise JujuAssertionError(
   484          'Logs failed to be migrated after {}'.format(timeout))
   485  
   486  
   487  def ensure_migration_rolls_back_on_failure(source_client, dest_client):
   488      """Must successfully roll back migration when migration fails.
   489  
   490      If the target controller becomes unavailable for the migration to complete
   491      the migration must roll back and continue to be available on the source
   492      controller.
   493      """
   494      test_model, application = deploy_simple_server_to_new_model(
   495          source_client, 'rollmeback')
   496      if after_22beta4(source_client.version):
   497          test_model.juju(
   498              'migrate',
   499              (test_model.env.environment, dest_client.env.controller.name),
   500              include_e=False)
   501      else:
   502          test_model.controller_juju(
   503              'migrate',
   504              (test_model.env.environment, dest_client.env.controller.name))
   505      # Once migration has started interrupt it
   506      wait_for_migrating(test_model)
   507      log.info('Disrupting target controller to force rollback')
   508      with disable_apiserver(dest_client.get_controller_client()):
   509          # Wait for model to be back and working on the original controller.
   510          log.info('Waiting for migration rollback to complete.')
   511          wait_for_model(test_model, test_model.env.environment)
   512          test_model.wait_for_started()
   513          assert_deployed_charm_is_responding(test_model)
   514          ensure_model_is_functional(test_model, application)
   515      test_model.remove_service(application)
   516      log.info('SUCCESS: migration rolled back.')
   517  
   518  
   519  @contextmanager
   520  def disable_apiserver(admin_client, machine_number='0'):
   521      """Disable the api server on the machine number provided.
   522  
   523      For the duration of the context manager stop the apiserver process on the
   524      controller machine.
   525      """
   526      rem_client = get_remote_for_controller(admin_client)
   527      try:
   528          rem_client.run(
   529              'sudo service jujud-machine-{} stop'.format(machine_number))
   530          yield
   531      finally:
   532          rem_client.run(
   533              'sudo service jujud-machine-{} start'.format(machine_number))
   534  
   535  
   536  def get_remote_for_controller(admin_client):
   537      """Get a remote client to the controller machine of `admin_client`.
   538  
   539      :return: remote.SSHRemote object for the controller machine.
   540      """
   541      status = admin_client.get_status()
   542      controller_ip = status.get_machine_dns_name('0')
   543      return remote_from_address(controller_ip)
   544  
   545  
   546  def ensure_migrating_with_insufficient_user_permissions_fails(
   547          source_client, dest_client, tmp_dir):
   548      """Ensure migration fails when a user does not have the right permissions.
   549  
   550      A non-superuser on a controller cannot migrate their models between
   551      controllers.
   552      """
   553      user_source_client, user_dest_client = create_user_on_controllers(
   554          source_client, dest_client, tmp_dir, 'failuser', 'add-model')
   555      user_new_model = deploy_dummy_source_to_new_model(
   556          user_source_client, 'user-fail')
   557      log.info('Attempting migration process')
   558      expect_migration_attempt_to_fail(user_new_model, user_dest_client)
   559  
   560  
   561  def ensure_migrating_with_superuser_user_permissions_succeeds(
   562          source_client, dest_client, tmp_dir):
   563      """Ensure migration succeeds when a user has superuser permissions
   564  
   565      A user with superuser permissions is able to migrate between controllers.
   566      """
   567      user_source_client, user_dest_client = create_user_on_controllers(
   568          source_client, dest_client, tmp_dir, 'passuser', 'superuser')
   569      user_new_model = deploy_dummy_source_to_new_model(
   570          user_source_client, 'super-permissions')
   571      log.info('Attempting migration process')
   572      migrate_model_to_controller(
   573          user_new_model, user_dest_client, include_user_name=True)
   574      log.info('SUCCESS: superuser migrated other user model.')
   575  
   576  
   577  def create_user_on_controllers(source_client, dest_client,
   578                                 tmp_dir, username, permission):
   579      """Create a user on both supplied controller with the permissions supplied.
   580  
   581      :param source_client: ModelClient object to create user on.
   582      :param dest_client: ModelClient object to create user on.
   583      :param tmp_dir: Path to base new users JUJU_DATA directory in.
   584      :param username: String of username to use.
   585      :param permission: String for permissions to grant user on both
   586        controllers. Valid values are `ModelClient.controller_permissions`.
   587      """
   588      new_user_home = os.path.join(tmp_dir, username)
   589      os.makedirs(new_user_home)
   590      new_user = User(username, 'write', [])
   591      source_user_client = source_client.register_user(new_user, new_user_home)
   592      source_client.grant(new_user.name, permission)
   593      second_controller_name = '{}_controllerb'.format(new_user.name)
   594      dest_user_client = dest_client.register_user(
   595          new_user,
   596          new_user_home,
   597          controller_name=second_controller_name)
   598      dest_client.grant(new_user.name, permission)
   599  
   600      return source_user_client, dest_user_client
   601  
   602  
   603  def expect_migration_attempt_to_fail(source_client, dest_client):
   604      """Ensure that the migration attempt fails due to permissions.
   605  
   606      As we're capturing the stderr output it after we're done with it so it
   607      appears in test logs.
   608      """
   609      try:
   610          if after_22beta4(source_client.version):
   611              args = [
   612                  '{}:{}'.format(
   613                      source_client.env.controller.name,
   614                      source_client.env.environment),
   615                  dest_client.env.controller.name
   616              ]
   617          else:
   618              args = [
   619                  '-c', source_client.env.controller.name,
   620                  source_client.env.environment,
   621                  dest_client.env.controller.name
   622              ]
   623          log_output = source_client.get_juju_output(
   624              'migrate', *args, merge_stderr=True, include_e=False)
   625      except CalledProcessError as e:
   626          print(e.output, file=sys.stderr)
   627          if 'permission denied' not in e.output:
   628              raise
   629          log.info('SUCCESS: Migrate command failed as expected.')
   630      else:
   631          print(log_output, file=sys.stderr)
   632          raise JujuAssertionError('Migration did not fail as expected.')
   633  
   634  
   635  def main(argv=None):
   636      args = parse_args(argv)
   637      configure_logging(args.verbose)
   638      bs1, bs2 = get_bootstrap_managers(args)
   639      assess_model_migration(bs1, bs2, args)
   640      return 0
   641  
   642  
   643  if __name__ == '__main__':
   644      sys.exit(main())