~juju-qa/juju-ci-tools/trunk

« back to all changes in this revision

Viewing changes to jujupy/version_client.py

  • Committer: Curtis Hovey
  • Date: 2017-01-25 02:32:29 UTC
  • mfrom: (1855 trunk)
  • mto: This revision was merged to the branch mainline in revision 1865.
  • Revision ID: curtis@canonical.com-20170125023229-g7c6bzt0cqe1j8g3
Merged trunk and resolved conflicts.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
from contextlib import contextmanager
 
2
import json
 
3
import logging
 
4
import os
 
5
import re
 
6
import subprocess
 
7
 
 
8
import yaml
 
9
 
 
10
from jujupy.client import (
 
11
    Controller,
 
12
    _DEFAULT_BUNDLE_TIMEOUT,
 
13
    get_cache_path,
 
14
    get_jenv_path,
 
15
    get_teardown_timeout,
 
16
    JESNotSupported,
 
17
    _jes_cmds,
 
18
    Juju2Backend,
 
19
    JujuData,
 
20
    KVM_MACHINE,
 
21
    LXC_MACHINE,
 
22
    make_safe_config,
 
23
    ModelClient,
 
24
    SimpleEnvironment,
 
25
    Status,
 
26
    StatusItem,
 
27
    SYSTEM,
 
28
    unqualified_model_name,
 
29
    UpgradeMongoNotSupported,
 
30
    )
 
31
from utility import (
 
32
    ensure_deleted,
 
33
    scoped_environ,
 
34
    split_address_port,
 
35
    )
 
36
 
 
37
 
 
38
log = logging.getLogger("jujupy.version_client")
 
39
 
 
40
 
 
41
class BootstrapMismatch(Exception):
 
42
 
 
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))
 
47
 
 
48
 
 
49
class IncompatibleConfigClass(Exception):
 
50
    """Raised when a client is initialised with the wrong config class."""
 
51
 
 
52
 
 
53
class VersionNotTestedError(Exception):
 
54
 
 
55
    def __init__(self, version):
 
56
        super(VersionNotTestedError, self).__init__(
 
57
            'Tests for juju {} are no longer supported.'.format(version))
 
58
 
 
59
 
 
60
class Juju1XBackend(Juju2Backend):
 
61
    """Backend for Juju 1.x versions.
 
62
 
 
63
    Uses -e to specify models ("environments", uses JUJU_HOME to specify home
 
64
    directory.
 
65
    """
 
66
 
 
67
    _model_flag = '-e'
 
68
 
 
69
    def shell_environ(self, used_feature_flags, juju_home):
 
70
        """Generate a suitable shell environment.
 
71
 
 
72
        For 2.0-alpha1 and earlier set only JUJU_HOME and not JUJU_DATA.
 
73
        """
 
74
        env = super(Juju1XBackend, self).shell_environ(used_feature_flags,
 
75
                                                       juju_home)
 
76
        env['JUJU_HOME'] = juju_home
 
77
        del env['JUJU_DATA']
 
78
        return env
 
79
 
 
80
 
 
81
class Status1X(Status):
 
82
 
 
83
    @property
 
84
    def model_name(self):
 
85
        return self.status['environment']
 
86
 
 
87
    def get_applications(self):
 
88
        return self.status.get('services', {})
 
89
 
 
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]
 
95
        condensed = {}
 
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')
 
99
        return condensed
 
100
 
 
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:
 
109
                yield StatusItem(
 
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:
 
117
                    yield StatusItem(
 
118
                        StatusItem.JUJU, unit_name,
 
119
                        {StatusItem.JUJU: unit_value[AGENT]})
 
120
                else:
 
121
                    yield StatusItem(StatusItem.JUJU, unit_name,
 
122
                                     self.condense_status(unit_value))
 
123
 
 
124
 
 
125
class ModelClient2_1(ModelClient):
 
126
    """Client for Juju 2.1"""
 
127
 
 
128
    REGION_ENDPOINT_PROMPT = 'Enter the API endpoint url for the region:'
 
129
 
 
130
 
 
131
class ModelClientRC(ModelClient2_1):
 
132
 
 
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."""
 
138
        if self.env.joyent:
 
139
            # Only accept kvm packages by requiring >1 cpu core, see lp:1446264
 
140
            constraints = 'mem=2G cpu-cores=1'
 
141
        else:
 
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,
 
148
                cloud_region,
 
149
                '--config', config_filename,
 
150
                '--default-model', self.env.environment]
 
151
        if upload_tools:
 
152
            if agent_version is not None:
 
153
                raise ValueError(
 
154
                    'agent-version may not be given with upload-tools.')
 
155
            args.insert(0, '--upload-tools')
 
156
        else:
 
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])
 
166
        if auto_upgrade:
 
167
            args.append('--auto-upgrade')
 
168
        if to is not None:
 
169
            args.extend(['--to', to])
 
170
        if no_gui:
 
171
            args.append('--no-gui')
 
172
        return tuple(args)
 
173
 
 
174
 
 
175
class EnvJujuClient1X(ModelClientRC):
 
176
    """Base for all 1.x client drivers."""
 
177
 
 
178
    default_backend = Juju1XBackend
 
179
 
 
180
    config_class = SimpleEnvironment
 
181
 
 
182
    status_class = Status1X
 
183
 
 
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()
 
187
 
 
188
    destroy_model_command = 'destroy-environment'
 
189
 
 
190
    supported_container_types = frozenset([KVM_MACHINE, LXC_MACHINE])
 
191
 
 
192
    agent_metadata_url = 'tools-metadata-url'
 
193
 
 
194
    _show_status = 'status'
 
195
 
 
196
    command_set_destroy_model = 'destroy-environment'
 
197
 
 
198
    command_set_all = 'all-changes'
 
199
 
 
200
    @classmethod
 
201
    def _get_env(cls, env):
 
202
        if isinstance(env, JujuData):
 
203
            raise IncompatibleConfigClass(
 
204
                'JujuData cannot be used with {}'.format(cls.__name__))
 
205
        return env
 
206
 
 
207
    def get_cache_path(self):
 
208
        return get_cache_path(self.env.juju_home, models=False)
 
209
 
 
210
    def remove_service(self, service):
 
211
        self.juju('destroy-service', (service,))
 
212
 
 
213
    def backup(self):
 
214
        environ = self._shell_environ()
 
215
        # juju-backup does not support the -e flag.
 
216
        environ['JUJU_ENV'] = self.env.environment
 
217
        try:
 
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:
 
225
            log.info(e.output)
 
226
            raise
 
227
        log.info(output)
 
228
        backup_file_pattern = re.compile('(juju-backup-[0-9-]+\.(t|tar.)gz)')
 
229
        match = backup_file_pattern.search(output)
 
230
        if match is None:
 
231
            raise Exception("The backup file was not found in output: %s" %
 
232
                            output)
 
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
 
237
 
 
238
    def restore_backup(self, backup_file):
 
239
        return self.get_juju_output('restore', '--constraints', 'mem=2G',
 
240
                                    backup_file)
 
241
 
 
242
    def restore_backup_async(self, backup_file):
 
243
        return self.juju_async('restore', ('--constraints', 'mem=2G',
 
244
                                           backup_file))
 
245
 
 
246
    def enable_ha(self):
 
247
        self.juju('ensure-availability', ('-n', '3'))
 
248
 
 
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))
 
252
 
 
253
    def list_clouds(self, format='json'):
 
254
        """List all the available clouds."""
 
255
        return {}
 
256
 
 
257
    def show_controller(self, format='json'):
 
258
        """Show controller's status."""
 
259
        return {}
 
260
 
 
261
    def get_models(self):
 
262
        """return a models dict with a 'models': [] key-value pair."""
 
263
        return {}
 
264
 
 
265
    def list_controllers(self):
 
266
        """List the controllers."""
 
267
        log.info(
 
268
            'The controller is environment {}'.format(self.env.environment))
 
269
 
 
270
    @staticmethod
 
271
    def get_controller_member_status(info_dict):
 
272
        return info_dict.get('state-server-member-status')
 
273
 
 
274
    def action_fetch(self, id, action=None, timeout="1m"):
 
275
        """Fetches the results of the action with the given id.
 
276
 
 
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.
 
281
        """
 
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":
 
287
            name = ""
 
288
            if action is not None:
 
289
                name = " " + action
 
290
            raise Exception(
 
291
                "timed out waiting for action%s to complete during fetch" %
 
292
                name)
 
293
        return out
 
294
 
 
295
    def action_do(self, unit, action, *args):
 
296
        """Performs the given action on the given unit.
 
297
 
 
298
        Action params should be given as args in the form foo=bar.
 
299
        Returns the id of the queued action.
 
300
        """
 
301
        args = (unit, action) + args
 
302
 
 
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)
 
309
        if match is None:
 
310
            raise Exception("Action id not found in output: %s" %
 
311
                            output)
 
312
        return match.group(1)
 
313
 
 
314
    def run(self, commands, applications):
 
315
        responses = self.get_juju_output(
 
316
            'run', '--format', 'json', '--service', ','.join(applications),
 
317
            *commands)
 
318
        return json.loads(responses)
 
319
 
 
320
    def list_space(self):
 
321
        return yaml.safe_load(self.get_juju_output('space list'))
 
322
 
 
323
    def add_space(self, space):
 
324
        self.juju('space create', (space),)
 
325
 
 
326
    def add_subnet(self, subnet, space):
 
327
        self.juju('subnet add', (subnet, space))
 
328
 
 
329
    def add_user_perms(self, username, models=None, permissions='read'):
 
330
        raise JESNotSupported()
 
331
 
 
332
    def grant(self, user_name, permission, model=None):
 
333
        raise JESNotSupported()
 
334
 
 
335
    def revoke(self, username, models=None, permissions='read'):
 
336
        raise JESNotSupported()
 
337
 
 
338
    def set_model_constraints(self, constraints):
 
339
        constraint_strings = self._dict_as_option_strings(constraints)
 
340
        return self.juju('set-constraints', constraint_strings)
 
341
 
 
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))
 
345
 
 
346
    def get_config(self, service):
 
347
        return yaml.safe_load(self.get_juju_output('get', service))
 
348
 
 
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'))
 
352
 
 
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)
 
356
 
 
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,))
 
361
 
 
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),))
 
365
 
 
366
    def get_model_defaults(self, model_key, cloud=None, region=None):
 
367
        log.info('No model-defaults stored for client (attempted get).')
 
368
 
 
369
    def set_model_defaults(self, model_key, value, cloud=None, region=None):
 
370
        log.info('No model-defaults stored for client (attempted set).')
 
371
 
 
372
    def unset_model_defaults(self, model_key, cloud=None, region=None):
 
373
        log.info('No model-defaults stored for client (attempted unset).')
 
374
 
 
375
    def _cmd_model(self, include_e, controller):
 
376
        if controller:
 
377
            return self.get_controller_model_name()
 
378
        elif self.env is None or not include_e:
 
379
            return None
 
380
        else:
 
381
            return unqualified_model_name(self.model_name)
 
382
 
 
383
    def update_user_name(self):
 
384
        return
 
385
 
 
386
    def _get_substrate_constraints(self):
 
387
        if self.env.joyent:
 
388
            # Only accept kvm packages by requiring >1 cpu core, see lp:1446264
 
389
            return 'mem=2G cpu-cores=1'
 
390
        else:
 
391
            return 'mem=2G'
 
392
 
 
393
    def get_bootstrap_args(self, upload_tools, bootstrap_series=None,
 
394
                           credential=None):
 
395
        """Return the bootstrap arguments for the substrate."""
 
396
        if credential is not None:
 
397
            raise ValueError(
 
398
                '--credential is not supported by this juju version.')
 
399
        constraints = self._get_substrate_constraints()
 
400
        args = ('--constraints', constraints)
 
401
        if upload_tools:
 
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',
 
408
                    env_val)
 
409
        return args
 
410
 
 
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)
 
416
 
 
417
    @contextmanager
 
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):
 
422
            yield
 
423
            log.info('Waiting for bootstrap of {}.'.format(
 
424
                self.env.environment))
 
425
 
 
426
    def get_jes_command(self):
 
427
        raise JESNotSupported()
 
428
 
 
429
    def enable_jes(self):
 
430
        raise JESNotSupported()
 
431
 
 
432
    def upgrade_juju(self, force_version=True):
 
433
        args = ()
 
434
        if force_version:
 
435
            version = self.get_matching_agent_version(no_build=True)
 
436
            args += ('--version', version)
 
437
        if self.env.local:
 
438
            args += ('--upload-tools',)
 
439
        self._upgrade_juju(args)
 
440
 
 
441
    def make_model_config(self):
 
442
        config_dict = make_safe_config(self)
 
443
        # Strip unneeded variables.
 
444
        return config_dict
 
445
 
 
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)
 
450
        else:
 
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)
 
454
 
 
455
    def destroy_model(self):
 
456
        """With JES enabled, destroy-environment destroys the model."""
 
457
        return self.destroy_environment(force=False)
 
458
 
 
459
    def kill_controller(self, check=False):
 
460
        """Destroy the environment, with force. Hard kill option.
 
461
 
 
462
        :return: Subprocess's exit code."""
 
463
        return self.juju(
 
464
            'destroy-environment', (self.env.environment, '--force', '-y'),
 
465
            check=check, include_e=False, timeout=get_teardown_timeout(self))
 
466
 
 
467
    def destroy_controller(self, all_models=False):
 
468
        """Destroy the environment, with force. Soft kill option.
 
469
 
 
470
        :param all_models: Ignored.
 
471
        :raises: subprocess.CalledProcessError if the operation fails."""
 
472
        return self.juju(
 
473
            'destroy-environment', (self.env.environment, '-y'),
 
474
            include_e=False, timeout=get_teardown_timeout(self))
 
475
 
 
476
    def destroy_environment(self, force=True, delete_jenv=False):
 
477
        if force:
 
478
            force_arg = ('--force',)
 
479
        else:
 
480
            force_arg = ()
 
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))
 
486
        if delete_jenv:
 
487
            jenv_path = get_jenv_path(self.env.juju_home, self.env.environment)
 
488
            ensure_deleted(jenv_path)
 
489
        return exit_status
 
490
 
 
491
    def _get_models(self):
 
492
        """return a list of model dicts."""
 
493
        try:
 
494
            return yaml.safe_load(self.get_juju_output(
 
495
                'environments', '-s', self.env.environment, '--format', 'yaml',
 
496
                include_e=False))
 
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.')
 
502
            return []
 
503
 
 
504
    def get_model_uuid(self):
 
505
        raise JESNotSupported()
 
506
 
 
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)
 
510
 
 
511
    def deployer(self, bundle_template, name=None, deploy_delay=10,
 
512
                 timeout=3600):
 
513
        """Deploy a bundle using deployer."""
 
514
        bundle = self.format_bundle(bundle_template)
 
515
        args = (
 
516
            '--debug',
 
517
            '--deploy-delay', str(deploy_delay),
 
518
            '--timeout', str(timeout),
 
519
            '--config', bundle,
 
520
        )
 
521
        if name:
 
522
            args += (name,)
 
523
        self.juju('deployer', args)
 
524
 
 
525
    def deploy(self, charm, repository=None, to=None, series=None,
 
526
               service=None, force=False, storage=None, constraints=None):
 
527
        args = [charm]
 
528
        if repository is not None:
 
529
            args.extend(['--repository', repository])
 
530
        if to is not None:
 
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))
 
539
 
 
540
    def upgrade_charm(self, service, charm_path=None):
 
541
        args = (service,)
 
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)
 
546
 
 
547
    def get_controller_client(self):
 
548
        """Return a client for the controller model.  May return self."""
 
549
        return self
 
550
 
 
551
    def get_controller_model_name(self):
 
552
        """Return the name of the 'controller' model.
 
553
 
 
554
        Return the name of the 1.x environment."""
 
555
        return self.env.environment
 
556
 
 
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)
 
561
 
 
562
    def upgrade_mongo(self):
 
563
        raise UpgradeMongoNotSupported()
 
564
 
 
565
    def create_cloned_environment(
 
566
            self, cloned_juju_home, controller_name, user_name=None):
 
567
        """Create a cloned environment.
 
568
 
 
569
        `user_name` is unused in this version of juju.
 
570
        """
 
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)
 
575
        return user_client
 
576
 
 
577
    def add_storage(self, unit, storage_type, amount="1"):
 
578
        """Add storage instances to service.
 
579
 
 
580
        Only type 'disk' is able to add instances.
 
581
        """
 
582
        self.juju('storage add', (unit, storage_type + "=" + amount))
 
583
 
 
584
    def list_storage(self):
 
585
        """Return the storage list."""
 
586
        return self.get_juju_output('storage list', '--format', 'json')
 
587
 
 
588
    def list_storage_pool(self):
 
589
        """Return the list of storage pool."""
 
590
        return self.get_juju_output('storage pool list', '--format', 'json')
 
591
 
 
592
    def create_storage_pool(self, name, provider, size):
 
593
        """Create storage pool."""
 
594
        self.juju('storage pool create',
 
595
                  (name, provider,
 
596
                   'size={}'.format(size)))
 
597
 
 
598
    def ssh_keys(self, full=False):
 
599
        """Give the ssh keys registered for the current model."""
 
600
        args = []
 
601
        if full:
 
602
            args.append('--full')
 
603
        return self.get_juju_output('authorized-keys list', *args)
 
604
 
 
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,
 
608
                                    merge_stderr=True)
 
609
 
 
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,
 
613
                                    merge_stderr=True)
 
614
 
 
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,
 
618
                                    merge_stderr=True)
 
619
 
 
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)
 
624
 
 
625
    def disable_command(self, command_set, message=''):
 
626
        """Disable a command-set."""
 
627
        return self.juju('block {}'.format(command_set), (message, ))
 
628
 
 
629
    def enable_command(self, args):
 
630
        """Enable a command-set."""
 
631
        return self.juju('unblock', args)
 
632
 
 
633
 
 
634
class EnvJujuClient22(EnvJujuClient1X):
 
635
 
 
636
    used_feature_flags = frozenset(['actions'])
 
637
 
 
638
    def __init__(self, *args, **kwargs):
 
639
        super(EnvJujuClient22, self).__init__(*args, **kwargs)
 
640
        self.feature_flags.add('actions')
 
641
 
 
642
 
 
643
class EnvJujuClient25(EnvJujuClient1X):
 
644
    """Drives Juju 2.5-series clients."""
 
645
 
 
646
    used_feature_flags = frozenset()
 
647
 
 
648
    def disable_jes(self):
 
649
        self.feature_flags.discard('jes')
 
650
 
 
651
 
 
652
class EnvJujuClient24(EnvJujuClient25):
 
653
    """Similar to EnvJujuClient25."""
 
654
 
 
655
    def add_ssh_machines(self, machines):
 
656
        for machine in machines:
 
657
            self.juju('add-machine', ('ssh:' + machine,))
 
658
 
 
659
 
 
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
 
679
    else:
 
680
        client_class = ModelClient
 
681
    return client_class
 
682
 
 
683
 
 
684
def client_from_config(config, juju_path, debug=False, soft_deadline=None):
 
685
    """Create a client from an environment's configuration.
 
686
 
 
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
 
692
        enforced.
 
693
    """
 
694
    version = ModelClient.get_version(juju_path)
 
695
    client_class = get_client_class(str(version))
 
696
    if config is None:
 
697
        env = client_class.config_class('', {})
 
698
    else:
 
699
        env = client_class.config_class.from_config(config)
 
700
    if juju_path is None:
 
701
        full_path = ModelClient.get_full_path()
 
702
    else:
 
703
        full_path = os.path.abspath(juju_path)
 
704
    return client_class(env, version, full_path, debug=debug,
 
705
                        soft_deadline=soft_deadline)