1
from argparse import ArgumentParser
2
from base64 import b64encode
3
from contextlib import contextmanager
5
from hashlib import sha512
6
from itertools import count
22
def check_juju_output(func):
23
def wrapper(*args, **kwargs):
24
result = func(*args, **kwargs)
25
if 'service' in result:
26
raise AssertionError('Result contained service')
31
class ControllerOperation(Exception):
33
def __init__(self, operation):
34
super(ControllerOperation, self).__init__(
35
'Operation "{}" is only valid on controller models.'.format(
39
def assert_juju_call(test_case, mock_method, client, expected_args,
41
if call_index is None:
42
test_case.assertEqual(len(mock_method.mock_calls), 1)
44
empty, args, kwargs = mock_method.mock_calls[call_index]
45
test_case.assertEqual(args, (expected_args,))
48
class FakeControllerState:
51
self.state = 'not-bootstrapped'
59
self.shares = ['admin']
61
def add_model(self, name):
62
state = FakeEnvironmentState(self)
64
self.models[name] = state
65
state.controller.state = 'created'
68
def require_controller(self, operation, name):
69
if name != self.controller_model.name:
70
raise ControllerOperation(operation)
72
def grant(self, username, permission):
73
model_permissions = ['read', 'write', 'admin']
74
if permission in model_permissions:
76
self.users[username]['access'] = permission
78
def add_user_perms(self, username, permissions):
80
{username: {'state': '', 'permission': permissions}})
81
self.shares.append(username)
83
def bootstrap(self, model_name, config, separate_controller):
84
default_model = self.add_model(model_name)
85
default_model.name = model_name
86
if separate_controller:
87
controller_model = default_model.controller.add_model('controller')
89
controller_model = default_model
90
self.controller_model = controller_model
91
controller_model.state_servers.append(controller_model.add_machine())
92
self.state = 'bootstrapped'
93
default_model.model_config = copy.deepcopy(config)
94
self.models[default_model.name] = default_model
98
class FakeEnvironmentState:
99
"""A Fake environment state that can be used by multiple FakeBackends."""
101
def __init__(self, controller=None):
103
if controller is not None:
104
self.controller = controller
106
self.controller = FakeControllerState()
110
self.machine_id_iter = count()
111
self.state_servers = []
113
self.machines = set()
118
self.machine_host_names = {}
119
self.current_bundle = None
120
self.model_config = None
125
return self.controller.state
127
def add_machine(self, host_name=None, machine_id=None):
128
if machine_id is None:
129
machine_id = str(self.machine_id_iter.next())
130
self.machines.add(machine_id)
131
if host_name is None:
132
host_name = '{}.example.com'.format(machine_id)
133
self.machine_host_names[machine_id] = host_name
136
def add_ssh_machines(self, machines):
137
for machine in machines:
140
def add_container(self, container_type, host=None, container_num=None):
142
host = self.add_machine()
143
host_containers = self.containers.setdefault(host, set())
144
if container_num is None:
145
same_type_containers = [x for x in host_containers if
147
container_num = len(same_type_containers)
148
container_name = '{}/{}/{}'.format(host, container_type, container_num)
149
host_containers.add(container_name)
150
host_name = '{}.example.com'.format(container_name)
151
self.machine_host_names[container_name] = host_name
153
def remove_container(self, container_id):
154
for containers in self.containers.values():
155
containers.discard(container_id)
157
def remove_machine(self, machine_id):
158
self.machines.remove(machine_id)
159
self.containers.pop(machine_id, None)
161
def remove_state_server(self, machine_id):
162
self.remove_machine(machine_id)
163
self.state_servers.remove(machine_id)
165
def destroy_environment(self):
167
self.controller.state = 'destroyed'
170
def kill_controller(self):
172
self.controller.state = 'controller-killed'
174
def destroy_model(self):
175
del self.controller.models[self.name]
177
self.controller.state = 'model-destroyed'
179
def _fail_stderr(self, message, returncode=1, cmd='juju', stdout=''):
180
exc = subprocess.CalledProcessError(returncode, cmd, stdout)
184
def restore_backup(self):
185
self.controller.require_controller('restore', self.name)
186
if len(self.state_servers) > 0:
187
return self._fail_stderr('Operation not permitted')
188
self.state_servers.append(self.add_machine())
191
self.controller.require_controller('enable-ha', self.name)
193
self.state_servers.append(self.add_machine())
195
def deploy(self, charm_name, service_name):
196
self.add_unit(service_name)
198
def deploy_bundle(self, bundle_path):
199
self.current_bundle = bundle_path
201
def add_unit(self, service_name):
202
machines = self.services.setdefault(service_name, set())
204
('{}/{}'.format(service_name, str(len(machines))),
207
def remove_unit(self, to_remove):
208
for units in self.services.values():
209
for unit_id, machine_id in units:
210
if unit_id == to_remove:
211
self.remove_machine(machine_id)
212
units.remove((unit_id, machine_id))
215
def destroy_service(self, service_name):
216
for unit, machine_id in self.services.pop(service_name):
217
self.remove_machine(machine_id)
219
def get_status_dict(self):
221
for machine_id in self.machines:
222
machine_dict = {'juju-status': {'current': 'idle'}}
223
hostname = self.machine_host_names.get(machine_id)
224
machine_dict['instance-id'] = machine_id
225
if hostname is not None:
226
machine_dict['dns-name'] = hostname
227
machines[machine_id] = machine_dict
228
if machine_id in self.state_servers:
229
machine_dict['controller-member-status'] = 'has-vote'
230
for host, containers in self.containers.items():
231
container_dict = dict((c, {}) for c in containers)
232
for container in containers:
233
dns_name = self.machine_host_names.get(container)
234
if dns_name is not None:
235
container_dict[container]['dns-name'] = dns_name
237
machines[host]['containers'] = container_dict
239
for service, units in self.services.items():
241
for unit_id, machine_id in units:
242
unit_map[unit_id] = {
243
'machine': machine_id,
244
'juju-status': {'current': 'idle'}}
245
services[service] = {
247
'relations': self.relations.get(service, {}),
248
'exposed': service in self.exposed,
250
return {'machines': machines, 'applications': services}
252
def add_ssh_key(self, keys_to_add):
254
for key in keys_to_add:
255
if not key.startswith("ssh-rsa "):
257
'cannot add key "{0}": invalid ssh key: {0}'.format(key))
258
elif key in self.ssh_keys:
260
'cannot add key "{0}": duplicate ssh key: {0}'.format(key))
262
self.ssh_keys.append(key)
263
return '\n'.join(errors)
265
def remove_ssh_key(self, keys_to_remove):
267
for i in reversed(range(len(keys_to_remove))):
268
key = keys_to_remove[i]
269
if key in ('juju-client-key', 'juju-system-key'):
270
keys_to_remove = keys_to_remove[:i] + keys_to_remove[i + 1:]
272
'cannot remove key id "{0}": may not delete internal key:'
274
for i in range(len(self.ssh_keys)):
275
if self.ssh_keys[i] in keys_to_remove:
276
keys_to_remove.remove(self.ssh_keys[i])
279
'cannot remove key id "{0}": invalid ssh key: {0}'.format(key)
280
for key in keys_to_remove)
281
return '\n'.join(errors)
283
def import_ssh_key(self, names_to_add):
284
for name in names_to_add:
285
self.ssh_keys.append('ssh-rsa FAKE_KEY a key {}'.format(name))
289
class FakeExpectChild:
291
def __init__(self, backend, juju_home, extra_env):
292
self.backend = backend
293
self.juju_home = juju_home
294
self.extra_env = extra_env
295
self.last_expect = None
296
self.exitstatus = None
298
def expect(self, line):
299
self.last_expect = line
301
def sendline(self, line):
302
"""Do-nothing implementation of sendline.
304
Subclassess will likely override this.
311
return bool(self.exitstatus is not None)
314
class AutoloadCredentials(FakeExpectChild):
316
def __init__(self, backend, juju_home, extra_env):
317
super(AutoloadCredentials, self).__init__(backend, juju_home,
321
def sendline(self, line):
322
if self.last_expect == (
323
'(Select the cloud it belongs to|'
324
'Enter cloud to which the credential).* Q to quit.*'):
328
juju_data = JujuData('foo', juju_home=self.juju_home)
329
juju_data.load_yaml()
330
creds = juju_data.credentials.setdefault('credentials', {})
331
creds.update({self.cloud: {
332
'default-region': self.extra_env['OS_REGION_NAME'],
333
self.extra_env['OS_USERNAME']: {
335
'auth-type': 'userpass',
336
'username': self.extra_env['OS_USERNAME'],
337
'password': self.extra_env['OS_PASSWORD'],
338
'tenant-name': self.extra_env['OS_TENANT_NAME'],
340
juju_data.dump_yaml(self.juju_home, {})
345
"""A fake juju backend for tests.
347
This is a partial implementation, but should be suitable for many uses,
350
The state is provided by controller_state, so that multiple clients and
351
backends can manipulate the same state.
354
def __init__(self, controller_state, feature_flags=None, version=None,
355
full_path=None, debug=False):
356
assert isinstance(controller_state, FakeControllerState)
357
self.controller_state = controller_state
358
if feature_flags is None:
359
feature_flags = set()
360
self.feature_flags = feature_flags
361
self.version = version
362
self.full_path = full_path
364
self.juju_timings = {}
365
self.log = logging.getLogger('jujupy')
367
def clone(self, full_path=None, version=None, debug=None,
370
version = self.version
371
if full_path is None:
372
full_path = self.full_path
375
if feature_flags is None:
376
feature_flags = set(self.feature_flags)
377
controller_state = self.controller_state
378
return self.__class__(controller_state, feature_flags, version,
381
def set_feature(self, feature, enabled):
383
self.feature_flags.add(feature)
385
self.feature_flags.discard(feature)
387
def is_feature_enabled(self, feature):
390
return bool(feature in self.feature_flags)
393
def ignore_soft_deadline(self):
397
def _check_timeouts(self):
400
def deploy(self, model_state, charm_name, service_name=None, series=None):
401
if service_name is None:
402
service_name = charm_name.split(':')[-1].split('/')[-1]
403
model_state.deploy(charm_name, service_name)
405
def bootstrap(self, args):
406
parser = ArgumentParser()
407
parser.add_argument('controller_name')
408
parser.add_argument('cloud_name_region')
409
parser.add_argument('--constraints')
410
parser.add_argument('--config')
411
parser.add_argument('--default-model')
412
parser.add_argument('--agent-version')
413
parser.add_argument('--bootstrap-series')
414
parser.add_argument('--upload-tools', action='store_true')
415
parsed = parser.parse_args(args)
416
with open(parsed.config) as config_file:
417
config = yaml.safe_load(config_file)
418
cloud_region = parsed.cloud_name_region.split('/', 1)
419
cloud = cloud_region[0]
420
# Although they are specified with specific arguments instead of as
421
# config, these values are listed by model-config:
422
# name, region, type (from cloud).
423
config['type'] = cloud
424
if len(cloud_region) > 1:
425
config['region'] = cloud_region[1]
426
config['name'] = parsed.default_model
427
if parsed.bootstrap_series is not None:
428
config['default-series'] = parsed.bootstrap_series
429
self.controller_state.bootstrap(parsed.default_model, config,
430
self.is_feature_enabled('jes'))
432
def quickstart(self, model_name, config, bundle):
433
default_model = self.controller_state.bootstrap(
434
model_name, config, self.is_feature_enabled('jes'))
435
default_model.deploy_bundle(bundle)
437
def destroy_environment(self, model_name):
439
state = self.controller_state.models[model_name]
442
state.destroy_environment()
445
def add_machines(self, model_state, args):
447
return model_state.add_machine()
448
ssh_machines = [a[4:] for a in args if a.startswith('ssh:')]
449
if len(ssh_machines) == len(args):
450
return model_state.add_ssh_machines(ssh_machines)
451
parser = ArgumentParser()
452
parser.add_argument('host_placement', nargs='*')
453
parser.add_argument('-n', type=int, dest='count', default='1')
454
parsed = parser.parse_args(args)
455
if len(parsed.host_placement) == 1:
456
split = parsed.host_placement[0].split(':')
458
container_type = split[0]
461
container_type, host = split
462
for x in range(parsed.count):
463
model_state.add_container(container_type, host=host)
465
for x in range(parsed.count):
466
model_state.add_machine()
468
def get_controller_model_name(self):
469
return self.controller_state.controller_model.name
471
def make_controller_dict(self, controller_name):
472
controller_model = self.controller_state.controller_model
473
server_id = list(controller_model.state_servers)[0]
474
server_hostname = controller_model.machine_host_names[server_id]
475
api_endpoint = '{}:23'.format(server_hostname)
476
return {controller_name: {'details': {'api-endpoints': [
479
def list_models(self):
480
model_names = [state.name for state in
481
self.controller_state.models.values()]
482
return {'models': [{'name': n} for n in model_names]}
484
def list_users(self):
485
user_names = [name for name in
486
self.controller_state.users.keys()]
490
append_dict = {'access': 'superuser', 'user-name': n,
493
access = self.controller_state.users[n]['access']
495
'access': access, 'user-name': n}
496
user_list.append(append_dict)
499
def show_user(self, user_name):
500
if user_name is None:
501
raise Exception("No user specified")
502
if user_name == 'admin':
503
user_status = {'access': 'superuser', 'user-name': user_name,
504
'display-name': user_name}
506
user_status = {'user-name': user_name, 'display-name': ''}
510
share_names = self.controller_state.shares
512
for key, value in self.controller_state.users.iteritems():
513
if key in share_names:
514
permissions.append(value['permission'])
516
for i, (share_name, permission) in enumerate(
517
zip(share_names, permissions)):
518
name = share_name + '@local'
519
share_list[name] = {'display-name': share_name,
520
'access': permission}
521
if name != 'admin@local':
522
share_list[name].pop('display-name')
524
share_list[name]['access'] = 'admin'
527
def show_model(self):
528
# To get data from the model we would need:
529
# self.controller_state.current_model
533
'owner': 'admin@local',
535
'status': {'current': 'available', 'since': '15 minutes ago'},
536
'users': self.get_users(),
538
return {model_name: data}
540
def _log_command(self, command, args, model, level=logging.INFO):
541
full_args = ['juju', command]
542
if model is not None:
543
full_args.extend(['-m', model])
544
full_args.extend(args)
545
self.log.log(level, ' '.join(full_args))
547
def juju(self, command, args, used_feature_flags,
548
juju_home, model=None, check=True, timeout=None, extra_env=None):
549
if 'service' in command:
550
raise Exception('Command names must not contain "service".')
551
if isinstance(args, basestring):
553
self._log_command(command, args, model)
554
if model is not None:
556
model = model.split(':')[1]
557
model_state = self.controller_state.models[model]
558
if command == 'enable-ha':
559
model_state.enable_ha()
560
if ((command, args[:1]) == ('set-config', ('dummy-source',)) or
561
(command, args[:1]) == ('config', ('dummy-source',))):
562
name, value = args[1].split('=')
564
model_state.token = value
565
if command == 'deploy':
566
parser = ArgumentParser()
567
parser.add_argument('charm_name')
568
parser.add_argument('service_name', nargs='?')
569
parser.add_argument('--to')
570
parser.add_argument('--series')
571
parsed = parser.parse_args(args)
572
self.deploy(model_state, parsed.charm_name,
573
parsed.service_name, parsed.series)
574
if command == 'remove-application':
575
model_state.destroy_service(*args)
576
if command == 'add-relation':
577
if args[0] == 'dummy-source':
578
model_state.relations[args[1]] = {'source': [args[0]]}
579
if command == 'expose':
581
model_state.exposed.add(service)
582
if command == 'unexpose':
584
model_state.exposed.remove(service)
585
if command == 'add-unit':
587
model_state.add_unit(service)
588
if command == 'remove-unit':
590
model_state.remove_unit(unit_id)
591
if command == 'add-machine':
592
return self.add_machines(model_state, args)
593
if command == 'remove-machine':
594
parser = ArgumentParser()
595
parser.add_argument('machine_id')
596
parser.add_argument('--force', action='store_true')
597
parsed = parser.parse_args(args)
598
machine_id = parsed.machine_id
599
if '/' in machine_id:
600
model_state.remove_container(machine_id)
602
model_state.remove_machine(machine_id)
603
if command == 'quickstart':
604
parser = ArgumentParser()
605
parser.add_argument('--constraints')
606
parser.add_argument('--no-browser', action='store_true')
607
parser.add_argument('bundle')
608
parsed = parser.parse_args(args)
609
# Released quickstart doesn't seem to provide the config via
611
self.quickstart(model, {}, parsed.bundle)
613
if command == 'bootstrap':
615
if command == 'kill-controller':
616
if self.controller_state.state == 'not-bootstrapped':
619
model_state = self.controller_state.models[model]
620
model_state.kill_controller()
621
if command == 'destroy-model':
622
if not self.is_feature_enabled('jes'):
623
raise JESNotSupported()
625
model_state = self.controller_state.models[model]
626
model_state.destroy_model()
627
if command == 'add-model':
628
if not self.is_feature_enabled('jes'):
629
raise JESNotSupported()
630
parser = ArgumentParser()
631
parser.add_argument('-c', '--controller')
632
parser.add_argument('--config')
633
parser.add_argument('model_name')
634
parsed = parser.parse_args(args)
635
self.controller_state.add_model(parsed.model_name)
636
if command == 'revoke':
638
permissions = args[3]
639
per = self.controller_state.users[user_name]['permission']
640
if per == permissions:
641
if permissions == 'read':
642
self.controller_state.shares.remove(user_name)
646
if command == 'grant':
649
self.controller_state.grant(username, permission)
650
if command == 'remove-user':
652
self.controller_state.users.pop(username)
653
if username in self.controller_state.shares:
654
self.controller_state.shares.remove(username)
655
if command == 'restore-backup':
656
model_state.restore_backup()
659
def juju_async(self, command, args, used_feature_flags,
660
juju_home, model=None, timeout=None):
662
self.juju(command, args, used_feature_flags,
663
juju_home, model, timeout=timeout)
666
def get_juju_output(self, command, args, used_feature_flags, juju_home,
667
model=None, timeout=None, user_name=None,
669
if 'service' in command:
670
raise Exception('No service')
671
self._log_command(command, args, model, logging.DEBUG)
672
if model is not None:
674
model = model.split(':')[1]
675
model_state = self.controller_state.models[model]
676
from deploy_stack import GET_TOKEN_SCRIPT
677
if (command, args) == ('ssh', ('dummy-sink/0', GET_TOKEN_SCRIPT)):
678
return model_state.token
679
if (command, args) == ('ssh', ('0', 'lsb_release', '-c')):
680
return 'Codename:\t{}\n'.format(
681
model_state.model_config['default-series'])
682
if command in ('model-config', 'get-model-config'):
683
return yaml.safe_dump(model_state.model_config)
684
if command == 'show-controller':
685
return yaml.safe_dump(self.make_controller_dict(args[0]))
686
if command == 'list-models':
687
return yaml.safe_dump(self.list_models())
688
if command == 'list-users':
689
return json.dumps(self.list_users())
690
if command == 'show-model':
691
return json.dumps(self.show_model())
692
if command == 'show-user':
693
return json.dumps(self.show_user(user_name))
694
if command == 'add-user':
696
if set(["--acl", "write"]).issubset(args):
697
permissions = 'write'
699
info_string = 'User "{}" added\n'.format(username)
700
self.controller_state.add_user_perms(username, permissions)
701
register_string = get_user_register_command_info(username)
702
return info_string + register_string
703
if command == 'show-status':
704
status_dict = model_state.get_status_dict()
705
# Parsing JSON is much faster than parsing YAML, and JSON is a
706
# subset of YAML, so emit JSON.
707
return json.dumps(status_dict)
708
if command == 'create-backup':
709
self.controller_state.require_controller('backup', model)
710
return 'juju-backup-0.tar.gz'
711
if command == 'ssh-keys':
712
lines = ['Keys used in model: ' + model_state.name]
714
lines.extend(model_state.ssh_keys)
716
lines.extend(':fake:fingerprint: ({})'.format(
717
k.split(' ', 2)[-1]) for k in model_state.ssh_keys)
718
return '\n'.join(lines)
719
if command == 'add-ssh-key':
720
return model_state.add_ssh_key(args)
721
if command == 'remove-ssh-key':
722
return model_state.remove_ssh_key(args)
723
if command == 'import-ssh-key':
724
return model_state.import_ssh_key(args)
727
def expect(self, command, args, used_feature_flags, juju_home, model=None,
728
timeout=None, extra_env=None):
729
if command == 'autoload-credentials':
730
return AutoloadCredentials(self, juju_home, extra_env)
732
return FakeExpectChild(self, juju_home, extra_env)
734
def pause(self, seconds):
738
def get_user_register_command_info(username):
739
code = get_user_register_token(username)
740
return 'Please send this command to {}\n juju register {}'.format(
744
def get_user_register_token(username):
745
return b64encode(sha512(username).digest())
748
class FakeBackend2B7(FakeBackend):
750
def juju(self, command, args, used_feature_flags,
751
juju_home, model=None, check=True, timeout=None, extra_env=None):
752
if model is not None:
753
model_state = self.controller_state.models[model]
754
if command == 'destroy-service':
755
model_state.destroy_service(*args)
756
if command == 'remove-service':
757
model_state.destroy_service(*args)
758
return super(FakeBackend2B7).juju(command, args, used_feature_flags,
759
juju_home, model, check, timeout,
763
class FakeBackendOptionalJES(FakeBackend):
765
def is_feature_enabled(self, feature):
766
return bool(feature in self.feature_flags)
769
def fake_juju_client(env=None, full_path=None, debug=False, version='2.0.0',
770
_backend=None, cls=EnvJujuClient):
772
env = JujuData('name', {
774
'default-series': 'angsty',
777
env.credentials = {'credentials': {'foo': {'creds': {}}}}
778
juju_home = env.juju_home
779
if juju_home is None:
782
backend_state = FakeControllerState()
783
_backend = FakeBackend(
784
backend_state, version=version, full_path=full_path,
786
_backend.set_feature('jes', True)
788
env, version, full_path, juju_home, debug, _backend=_backend)
789
client.bootstrap_replaces = {}
793
def fake_juju_client_optional_jes(env=None, full_path=None, debug=False,
794
jes_enabled=True, version='2.0.0',
797
backend_state = FakeControllerState()
798
_backend = FakeBackendOptionalJES(
799
backend_state, version=version, full_path=full_path,
801
_backend.set_feature('jes', jes_enabled)
802
client = fake_juju_client(env, full_path, debug, version, _backend,
803
cls=FakeJujuClientOptionalJES)
804
client.used_feature_flags = frozenset(['address-allocation', 'jes'])
808
class FakeJujuClientOptionalJES(EnvJujuClient):
810
def get_controller_model_name(self):
811
return self._backend.controller_state.controller_model.name