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())