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