1
from contextlib import contextmanager
10
from jujupy.client import (
12
_DEFAULT_BUNDLE_TIMEOUT,
28
unqualified_model_name,
29
UpgradeMongoNotSupported,
38
log = logging.getLogger("jujupy.version_client")
41
class BootstrapMismatch(Exception):
43
def __init__(self, arg_name, arg_val, env_name, env_val):
44
super(BootstrapMismatch, self).__init__(
45
'--{} {} does not match {}: {}'.format(
46
arg_name, arg_val, env_name, env_val))
49
class IncompatibleConfigClass(Exception):
50
"""Raised when a client is initialised with the wrong config class."""
53
class VersionNotTestedError(Exception):
55
def __init__(self, version):
56
super(VersionNotTestedError, self).__init__(
57
'Tests for juju {} are no longer supported.'.format(version))
60
class Juju1XBackend(Juju2Backend):
61
"""Backend for Juju 1.x versions.
63
Uses -e to specify models ("environments", uses JUJU_HOME to specify home
69
def shell_environ(self, used_feature_flags, juju_home):
70
"""Generate a suitable shell environment.
72
For 2.0-alpha1 and earlier set only JUJU_HOME and not JUJU_DATA.
74
env = super(Juju1XBackend, self).shell_environ(used_feature_flags,
76
env['JUJU_HOME'] = juju_home
81
class Status1X(Status):
85
return self.status['environment']
87
def get_applications(self):
88
return self.status.get('services', {})
90
def condense_status(self, item_value):
91
"""Condense the scattered agent-* fields into a status dict."""
92
def shift_field(dest_dict, dest_name, src_dict, src_name):
93
if src_name in src_dict:
94
dest_dict[dest_name] = src_dict[src_name]
96
shift_field(condensed, 'current', item_value, 'agent-state')
97
shift_field(condensed, 'version', item_value, 'agent-version')
98
shift_field(condensed, 'message', item_value, 'agent-state-info')
101
def iter_status(self):
102
SERVICE = 'service-status'
103
AGENT = 'agent-status'
104
for machine_name, machine_value in self.get_machines({}).items():
105
yield StatusItem(StatusItem.JUJU, machine_name,
106
self.condense_status(machine_value))
107
for app_name, app_value in self.get_applications().items():
108
if SERVICE in app_value:
110
StatusItem.APPLICATION, app_name,
111
{StatusItem.APPLICATION: app_value[SERVICE]})
112
for unit_name, unit_value in app_value.get('units', {}).items():
113
if StatusItem.WORKLOAD in unit_value:
114
yield StatusItem(StatusItem.WORKLOAD,
115
unit_name, unit_value)
116
if AGENT in unit_value:
118
StatusItem.JUJU, unit_name,
119
{StatusItem.JUJU: unit_value[AGENT]})
121
yield StatusItem(StatusItem.JUJU, unit_name,
122
self.condense_status(unit_value))
125
class ModelClient2_1(ModelClient):
126
"""Client for Juju 2.1"""
128
REGION_ENDPOINT_PROMPT = 'Enter the API endpoint url for the region:'
131
class ModelClientRC(ModelClient2_1):
133
def get_bootstrap_args(
134
self, upload_tools, config_filename, bootstrap_series=None,
135
credential=None, auto_upgrade=False, metadata_source=None,
136
to=None, no_gui=False, agent_version=None):
137
"""Return the bootstrap arguments for the substrate."""
139
# Only accept kvm packages by requiring >1 cpu core, see lp:1446264
140
constraints = 'mem=2G cpu-cores=1'
142
constraints = 'mem=2G'
143
cloud_region = self.get_cloud_region(self.env.get_cloud(),
144
self.env.get_region())
145
# Note controller name before cloud_region
146
args = ['--constraints', constraints,
147
self.env.environment,
149
'--config', config_filename,
150
'--default-model', self.env.environment]
152
if agent_version is not None:
154
'agent-version may not be given with upload-tools.')
155
args.insert(0, '--upload-tools')
157
if agent_version is None:
158
agent_version = self.get_matching_agent_version()
159
args.extend(['--agent-version', agent_version])
160
if bootstrap_series is not None:
161
args.extend(['--bootstrap-series', bootstrap_series])
162
if credential is not None:
163
args.extend(['--credential', credential])
164
if metadata_source is not None:
165
args.extend(['--metadata-source', metadata_source])
167
args.append('--auto-upgrade')
169
args.extend(['--to', to])
171
args.append('--no-gui')
175
class EnvJujuClient1X(ModelClientRC):
176
"""Base for all 1.x client drivers."""
178
default_backend = Juju1XBackend
180
config_class = SimpleEnvironment
182
status_class = Status1X
184
# The environments.yaml options that are replaced by bootstrap options.
185
# For Juju 1.x, no bootstrap options are used.
186
bootstrap_replaces = frozenset()
188
destroy_model_command = 'destroy-environment'
190
supported_container_types = frozenset([KVM_MACHINE, LXC_MACHINE])
192
agent_metadata_url = 'tools-metadata-url'
194
_show_status = 'status'
196
command_set_destroy_model = 'destroy-environment'
198
command_set_all = 'all-changes'
201
def _get_env(cls, env):
202
if isinstance(env, JujuData):
203
raise IncompatibleConfigClass(
204
'JujuData cannot be used with {}'.format(cls.__name__))
207
def get_cache_path(self):
208
return get_cache_path(self.env.juju_home, models=False)
210
def remove_service(self, service):
211
self.juju('destroy-service', (service,))
214
environ = self._shell_environ()
215
# juju-backup does not support the -e flag.
216
environ['JUJU_ENV'] = self.env.environment
218
# Mutate os.environ instead of supplying env parameter so Windows
219
# can search env['PATH']
220
with scoped_environ(environ):
221
args = ['juju', 'backup']
222
log.info(' '.join(args))
223
output = subprocess.check_output(args)
224
except subprocess.CalledProcessError as e:
228
backup_file_pattern = re.compile('(juju-backup-[0-9-]+\.(t|tar.)gz)')
229
match = backup_file_pattern.search(output)
231
raise Exception("The backup file was not found in output: %s" %
233
backup_file_name = match.group(1)
234
backup_file_path = os.path.abspath(backup_file_name)
235
log.info("State-Server backup at %s", backup_file_path)
236
return backup_file_path
238
def restore_backup(self, backup_file):
239
return self.get_juju_output('restore', '--constraints', 'mem=2G',
242
def restore_backup_async(self, backup_file):
243
return self.juju_async('restore', ('--constraints', 'mem=2G',
247
self.juju('ensure-availability', ('-n', '3'))
249
def list_models(self):
250
"""List the models registered with the current controller."""
251
log.info('The model is environment {}'.format(self.env.environment))
253
def list_clouds(self, format='json'):
254
"""List all the available clouds."""
257
def show_controller(self, format='json'):
258
"""Show controller's status."""
261
def get_models(self):
262
"""return a models dict with a 'models': [] key-value pair."""
265
def list_controllers(self):
266
"""List the controllers."""
268
'The controller is environment {}'.format(self.env.environment))
271
def get_controller_member_status(info_dict):
272
return info_dict.get('state-server-member-status')
274
def action_fetch(self, id, action=None, timeout="1m"):
275
"""Fetches the results of the action with the given id.
277
Will wait for up to 1 minute for the action results.
278
The action name here is just used for an more informational error in
279
cases where it's available.
280
Returns the yaml output of the fetched action.
282
# the command has to be "action fetch" so that the -e <env> args are
283
# placed after "fetch", since that's where action requires them to be.
284
out = self.get_juju_output("action fetch", id, "--wait", timeout)
285
status = yaml.safe_load(out)["status"]
286
if status != "completed":
288
if action is not None:
291
"timed out waiting for action%s to complete during fetch" %
295
def action_do(self, unit, action, *args):
296
"""Performs the given action on the given unit.
298
Action params should be given as args in the form foo=bar.
299
Returns the id of the queued action.
301
args = (unit, action) + args
303
# the command has to be "action do" so that the -e <env> args are
304
# placed after "do", since that's where action requires them to be.
305
output = self.get_juju_output("action do", *args)
306
action_id_pattern = re.compile(
307
'Action queued with id: ([a-f0-9\-]{36})')
308
match = action_id_pattern.search(output)
310
raise Exception("Action id not found in output: %s" %
312
return match.group(1)
314
def run(self, commands, applications):
315
responses = self.get_juju_output(
316
'run', '--format', 'json', '--service', ','.join(applications),
318
return json.loads(responses)
320
def list_space(self):
321
return yaml.safe_load(self.get_juju_output('space list'))
323
def add_space(self, space):
324
self.juju('space create', (space),)
326
def add_subnet(self, subnet, space):
327
self.juju('subnet add', (subnet, space))
329
def add_user_perms(self, username, models=None, permissions='read'):
330
raise JESNotSupported()
332
def grant(self, user_name, permission, model=None):
333
raise JESNotSupported()
335
def revoke(self, username, models=None, permissions='read'):
336
raise JESNotSupported()
338
def set_model_constraints(self, constraints):
339
constraint_strings = self._dict_as_option_strings(constraints)
340
return self.juju('set-constraints', constraint_strings)
342
def set_config(self, service, options):
343
option_strings = ['{}={}'.format(*item) for item in options.items()]
344
self.juju('set', (service,) + tuple(option_strings))
346
def get_config(self, service):
347
return yaml.safe_load(self.get_juju_output('get', service))
349
def get_model_config(self):
350
"""Return the value of the environment's configured option."""
351
return yaml.safe_load(self.get_juju_output('get-env'))
353
def get_env_option(self, option):
354
"""Return the value of the environment's configured option."""
355
return self.get_juju_output('get-env', option)
357
def set_env_option(self, option, value):
358
"""Set the value of the option in the environment."""
359
option_value = "%s=%s" % (option, value)
360
return self.juju('set-env', (option_value,))
362
def unset_env_option(self, option):
363
"""Unset the value of the option in the environment."""
364
return self.juju('set-env', ('{}='.format(option),))
366
def get_model_defaults(self, model_key, cloud=None, region=None):
367
log.info('No model-defaults stored for client (attempted get).')
369
def set_model_defaults(self, model_key, value, cloud=None, region=None):
370
log.info('No model-defaults stored for client (attempted set).')
372
def unset_model_defaults(self, model_key, cloud=None, region=None):
373
log.info('No model-defaults stored for client (attempted unset).')
375
def _cmd_model(self, include_e, controller):
377
return self.get_controller_model_name()
378
elif self.env is None or not include_e:
381
return unqualified_model_name(self.model_name)
383
def update_user_name(self):
386
def _get_substrate_constraints(self):
388
# Only accept kvm packages by requiring >1 cpu core, see lp:1446264
389
return 'mem=2G cpu-cores=1'
393
def get_bootstrap_args(self, upload_tools, bootstrap_series=None,
395
"""Return the bootstrap arguments for the substrate."""
396
if credential is not None:
398
'--credential is not supported by this juju version.')
399
constraints = self._get_substrate_constraints()
400
args = ('--constraints', constraints)
402
args = ('--upload-tools',) + args
403
if bootstrap_series is not None:
404
env_val = self.env.get_option('default-series')
405
if bootstrap_series != env_val:
406
raise BootstrapMismatch(
407
'bootstrap-series', bootstrap_series, 'default-series',
411
def bootstrap(self, upload_tools=False, bootstrap_series=None):
412
"""Bootstrap a controller."""
413
self._check_bootstrap()
414
args = self.get_bootstrap_args(upload_tools, bootstrap_series)
415
self.juju('bootstrap', args)
418
def bootstrap_async(self, upload_tools=False):
419
self._check_bootstrap()
420
args = self.get_bootstrap_args(upload_tools)
421
with self.juju_async('bootstrap', args):
423
log.info('Waiting for bootstrap of {}.'.format(
424
self.env.environment))
426
def get_jes_command(self):
427
raise JESNotSupported()
429
def enable_jes(self):
430
raise JESNotSupported()
432
def upgrade_juju(self, force_version=True):
435
version = self.get_matching_agent_version(no_build=True)
436
args += ('--version', version)
438
args += ('--upload-tools',)
439
self._upgrade_juju(args)
441
def make_model_config(self):
442
config_dict = make_safe_config(self)
443
# Strip unneeded variables.
446
def _add_model(self, model_name, config_file):
447
seen_cmd = self.get_jes_command()
448
if seen_cmd == SYSTEM:
449
controller_option = ('-s', self.env.environment)
451
controller_option = ('-c', self.env.environment)
452
self.juju(_jes_cmds[seen_cmd]['create'], controller_option + (
453
model_name, '--config', config_file), include_e=False)
455
def destroy_model(self):
456
"""With JES enabled, destroy-environment destroys the model."""
457
return self.destroy_environment(force=False)
459
def kill_controller(self, check=False):
460
"""Destroy the environment, with force. Hard kill option.
462
:return: Subprocess's exit code."""
464
'destroy-environment', (self.env.environment, '--force', '-y'),
465
check=check, include_e=False, timeout=get_teardown_timeout(self))
467
def destroy_controller(self, all_models=False):
468
"""Destroy the environment, with force. Soft kill option.
470
:param all_models: Ignored.
471
:raises: subprocess.CalledProcessError if the operation fails."""
473
'destroy-environment', (self.env.environment, '-y'),
474
include_e=False, timeout=get_teardown_timeout(self))
476
def destroy_environment(self, force=True, delete_jenv=False):
478
force_arg = ('--force',)
481
exit_status = self.juju(
482
'destroy-environment',
483
(self.env.environment,) + force_arg + ('-y',),
484
check=False, include_e=False,
485
timeout=get_teardown_timeout(self))
487
jenv_path = get_jenv_path(self.env.juju_home, self.env.environment)
488
ensure_deleted(jenv_path)
491
def _get_models(self):
492
"""return a list of model dicts."""
494
return yaml.safe_load(self.get_juju_output(
495
'environments', '-s', self.env.environment, '--format', 'yaml',
497
except subprocess.CalledProcessError:
498
# This *private* method attempts to use a 1.25 JES feature.
499
# The JES design is dead. The private method is not used to
500
# directly test juju cli; the failure is not a contract violation.
501
log.info('Call to JES juju environments failed, falling back.')
504
def get_model_uuid(self):
505
raise JESNotSupported()
507
def deploy_bundle(self, bundle, timeout=_DEFAULT_BUNDLE_TIMEOUT):
508
"""Deploy bundle using deployer for Juju 1.X version."""
509
self.deployer(bundle, timeout=timeout)
511
def deployer(self, bundle_template, name=None, deploy_delay=10,
513
"""Deploy a bundle using deployer."""
514
bundle = self.format_bundle(bundle_template)
517
'--deploy-delay', str(deploy_delay),
518
'--timeout', str(timeout),
523
self.juju('deployer', args)
525
def deploy(self, charm, repository=None, to=None, series=None,
526
service=None, force=False, storage=None, constraints=None):
528
if repository is not None:
529
args.extend(['--repository', repository])
531
args.extend(['--to', to])
532
if service is not None:
533
args.extend([service])
534
if storage is not None:
535
args.extend(['--storage', storage])
536
if constraints is not None:
537
args.extend(['--constraints', constraints])
538
return self.juju('deploy', tuple(args))
540
def upgrade_charm(self, service, charm_path=None):
542
if charm_path is not None:
543
repository = os.path.dirname(os.path.dirname(charm_path))
544
args = args + ('--repository', repository)
545
self.juju('upgrade-charm', args)
547
def get_controller_client(self):
548
"""Return a client for the controller model. May return self."""
551
def get_controller_model_name(self):
552
"""Return the name of the 'controller' model.
554
Return the name of the 1.x environment."""
555
return self.env.environment
557
def get_controller_endpoint(self):
558
"""Return the host and port of the state-server leader."""
559
endpoint = self.get_juju_output('api-endpoints')
560
return split_address_port(endpoint)
562
def upgrade_mongo(self):
563
raise UpgradeMongoNotSupported()
565
def create_cloned_environment(
566
self, cloned_juju_home, controller_name, user_name=None):
567
"""Create a cloned environment.
569
`user_name` is unused in this version of juju.
571
user_client = self.clone(env=self.env.clone())
572
user_client.env.juju_home = cloned_juju_home
573
# New user names the controller.
574
user_client.env.controller = Controller(controller_name)
577
def add_storage(self, unit, storage_type, amount="1"):
578
"""Add storage instances to service.
580
Only type 'disk' is able to add instances.
582
self.juju('storage add', (unit, storage_type + "=" + amount))
584
def list_storage(self):
585
"""Return the storage list."""
586
return self.get_juju_output('storage list', '--format', 'json')
588
def list_storage_pool(self):
589
"""Return the list of storage pool."""
590
return self.get_juju_output('storage pool list', '--format', 'json')
592
def create_storage_pool(self, name, provider, size):
593
"""Create storage pool."""
594
self.juju('storage pool create',
596
'size={}'.format(size)))
598
def ssh_keys(self, full=False):
599
"""Give the ssh keys registered for the current model."""
602
args.append('--full')
603
return self.get_juju_output('authorized-keys list', *args)
605
def add_ssh_key(self, *keys):
606
"""Add one or more ssh keys to the current model."""
607
return self.get_juju_output('authorized-keys add', *keys,
610
def remove_ssh_key(self, *keys):
611
"""Remove one or more ssh keys from the current model."""
612
return self.get_juju_output('authorized-keys delete', *keys,
615
def import_ssh_key(self, *keys):
616
"""Import ssh keys from one or more identities to the current model."""
617
return self.get_juju_output('authorized-keys import', *keys,
620
def list_disabled_commands(self):
621
"""List all the commands disabled on the model."""
622
raw = self.get_juju_output('block list', '--format', 'yaml')
623
return yaml.safe_load(raw)
625
def disable_command(self, command_set, message=''):
626
"""Disable a command-set."""
627
return self.juju('block {}'.format(command_set), (message, ))
629
def enable_command(self, args):
630
"""Enable a command-set."""
631
return self.juju('unblock', args)
634
class EnvJujuClient22(EnvJujuClient1X):
636
used_feature_flags = frozenset(['actions'])
638
def __init__(self, *args, **kwargs):
639
super(EnvJujuClient22, self).__init__(*args, **kwargs)
640
self.feature_flags.add('actions')
643
class EnvJujuClient25(EnvJujuClient1X):
644
"""Drives Juju 2.5-series clients."""
646
used_feature_flags = frozenset()
648
def disable_jes(self):
649
self.feature_flags.discard('jes')
652
class EnvJujuClient24(EnvJujuClient25):
653
"""Similar to EnvJujuClient25."""
655
def add_ssh_machines(self, machines):
656
for machine in machines:
657
self.juju('add-machine', ('ssh:' + machine,))
660
def get_client_class(version):
661
if version.startswith('1.16'):
662
raise VersionNotTestedError(version)
663
elif re.match('^1\.22[.-]', version):
664
client_class = EnvJujuClient22
665
elif re.match('^1\.24[.-]', version):
666
client_class = EnvJujuClient24
667
elif re.match('^1\.25[.-]', version):
668
client_class = EnvJujuClient25
669
elif re.match('^1\.26[.-]', version):
670
raise VersionNotTestedError(version)
671
elif re.match('^1\.', version):
672
client_class = EnvJujuClient1X
673
elif re.match('^2\.0-(alpha|beta)', version):
674
raise VersionNotTestedError(version)
675
elif re.match('^2\.0-rc[1-3]', version):
676
client_class = ModelClientRC
677
elif re.match('^2\.[0-1][.-]', version):
678
client_class = ModelClient2_1
680
client_class = ModelClient
684
def client_from_config(config, juju_path, debug=False, soft_deadline=None):
685
"""Create a client from an environment's configuration.
687
:param config: Name of the environment to use the config from.
688
:param juju_path: Path to juju binary the client should wrap.
689
:param debug=False: The debug flag for the client, False by default.
690
:param soft_deadline: A datetime representing the deadline by which
691
normal operations should complete. If None, no deadline is
694
version = ModelClient.get_version(juju_path)
695
client_class = get_client_class(str(version))
697
env = client_class.config_class('', {})
699
env = client_class.config_class.from_config(config)
700
if juju_path is None:
701
full_path = ModelClient.get_full_path()
703
full_path = os.path.abspath(juju_path)
704
return client_class(env, version, full_path, debug=debug,
705
soft_deadline=soft_deadline)