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