~james-page/charms/trusty/swift-proxy/trunk

« back to all changes in this revision

Viewing changes to charmhelpers/core/hookenv.py

  • Committer: James Page
  • Date: 2016-01-19 14:46:01 UTC
  • mfrom: (134.1.1 stable.remote)
  • Revision ID: james.page@ubuntu.com-20160119144601-66bdh4r0va0pn9og
Fix liberty/mitaka typo from previous test definition update batch.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2014-2015 Canonical Limited.
 
2
#
 
3
# This file is part of charm-helpers.
 
4
#
 
5
# charm-helpers is free software: you can redistribute it and/or modify
 
6
# it under the terms of the GNU Lesser General Public License version 3 as
 
7
# published by the Free Software Foundation.
 
8
#
 
9
# charm-helpers is distributed in the hope that it will be useful,
 
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
12
# GNU Lesser General Public License for more details.
 
13
#
 
14
# You should have received a copy of the GNU Lesser General Public License
 
15
# along with charm-helpers.  If not, see <http://www.gnu.org/licenses/>.
 
16
 
 
17
"Interactions with the Juju environment"
 
18
# Copyright 2013 Canonical Ltd.
 
19
#
 
20
# Authors:
 
21
#  Charm Helpers Developers <juju@lists.ubuntu.com>
 
22
 
 
23
from __future__ import print_function
 
24
import copy
 
25
from distutils.version import LooseVersion
 
26
from functools import wraps
 
27
import glob
 
28
import os
 
29
import json
 
30
import yaml
 
31
import subprocess
 
32
import sys
 
33
import errno
 
34
import tempfile
 
35
from subprocess import CalledProcessError
 
36
 
 
37
import six
 
38
if not six.PY3:
 
39
    from UserDict import UserDict
 
40
else:
 
41
    from collections import UserDict
 
42
 
 
43
CRITICAL = "CRITICAL"
 
44
ERROR = "ERROR"
 
45
WARNING = "WARNING"
 
46
INFO = "INFO"
 
47
DEBUG = "DEBUG"
 
48
MARKER = object()
 
49
 
 
50
cache = {}
 
51
 
 
52
 
 
53
def cached(func):
 
54
    """Cache return values for multiple executions of func + args
 
55
 
 
56
    For example::
 
57
 
 
58
        @cached
 
59
        def unit_get(attribute):
 
60
            pass
 
61
 
 
62
        unit_get('test')
 
63
 
 
64
    will cache the result of unit_get + 'test' for future calls.
 
65
    """
 
66
    @wraps(func)
 
67
    def wrapper(*args, **kwargs):
 
68
        global cache
 
69
        key = str((func, args, kwargs))
 
70
        try:
 
71
            return cache[key]
 
72
        except KeyError:
 
73
            pass  # Drop out of the exception handler scope.
 
74
        res = func(*args, **kwargs)
 
75
        cache[key] = res
 
76
        return res
 
77
    wrapper._wrapped = func
 
78
    return wrapper
 
79
 
 
80
 
 
81
def flush(key):
 
82
    """Flushes any entries from function cache where the
 
83
    key is found in the function+args """
 
84
    flush_list = []
 
85
    for item in cache:
 
86
        if key in item:
 
87
            flush_list.append(item)
 
88
    for item in flush_list:
 
89
        del cache[item]
 
90
 
 
91
 
 
92
def log(message, level=None):
 
93
    """Write a message to the juju log"""
 
94
    command = ['juju-log']
 
95
    if level:
 
96
        command += ['-l', level]
 
97
    if not isinstance(message, six.string_types):
 
98
        message = repr(message)
 
99
    command += [message]
 
100
    # Missing juju-log should not cause failures in unit tests
 
101
    # Send log output to stderr
 
102
    try:
 
103
        subprocess.call(command)
 
104
    except OSError as e:
 
105
        if e.errno == errno.ENOENT:
 
106
            if level:
 
107
                message = "{}: {}".format(level, message)
 
108
            message = "juju-log: {}".format(message)
 
109
            print(message, file=sys.stderr)
 
110
        else:
 
111
            raise
 
112
 
 
113
 
 
114
class Serializable(UserDict):
 
115
    """Wrapper, an object that can be serialized to yaml or json"""
 
116
 
 
117
    def __init__(self, obj):
 
118
        # wrap the object
 
119
        UserDict.__init__(self)
 
120
        self.data = obj
 
121
 
 
122
    def __getattr__(self, attr):
 
123
        # See if this object has attribute.
 
124
        if attr in ("json", "yaml", "data"):
 
125
            return self.__dict__[attr]
 
126
        # Check for attribute in wrapped object.
 
127
        got = getattr(self.data, attr, MARKER)
 
128
        if got is not MARKER:
 
129
            return got
 
130
        # Proxy to the wrapped object via dict interface.
 
131
        try:
 
132
            return self.data[attr]
 
133
        except KeyError:
 
134
            raise AttributeError(attr)
 
135
 
 
136
    def __getstate__(self):
 
137
        # Pickle as a standard dictionary.
 
138
        return self.data
 
139
 
 
140
    def __setstate__(self, state):
 
141
        # Unpickle into our wrapper.
 
142
        self.data = state
 
143
 
 
144
    def json(self):
 
145
        """Serialize the object to json"""
 
146
        return json.dumps(self.data)
 
147
 
 
148
    def yaml(self):
 
149
        """Serialize the object to yaml"""
 
150
        return yaml.dump(self.data)
 
151
 
 
152
 
 
153
def execution_environment():
 
154
    """A convenient bundling of the current execution context"""
 
155
    context = {}
 
156
    context['conf'] = config()
 
157
    if relation_id():
 
158
        context['reltype'] = relation_type()
 
159
        context['relid'] = relation_id()
 
160
        context['rel'] = relation_get()
 
161
    context['unit'] = local_unit()
 
162
    context['rels'] = relations()
 
163
    context['env'] = os.environ
 
164
    return context
 
165
 
 
166
 
 
167
def in_relation_hook():
 
168
    """Determine whether we're running in a relation hook"""
 
169
    return 'JUJU_RELATION' in os.environ
 
170
 
 
171
 
 
172
def relation_type():
 
173
    """The scope for the current relation hook"""
 
174
    return os.environ.get('JUJU_RELATION', None)
 
175
 
 
176
 
 
177
@cached
 
178
def relation_id(relation_name=None, service_or_unit=None):
 
179
    """The relation ID for the current or a specified relation"""
 
180
    if not relation_name and not service_or_unit:
 
181
        return os.environ.get('JUJU_RELATION_ID', None)
 
182
    elif relation_name and service_or_unit:
 
183
        service_name = service_or_unit.split('/')[0]
 
184
        for relid in relation_ids(relation_name):
 
185
            remote_service = remote_service_name(relid)
 
186
            if remote_service == service_name:
 
187
                return relid
 
188
    else:
 
189
        raise ValueError('Must specify neither or both of relation_name and service_or_unit')
 
190
 
 
191
 
 
192
def local_unit():
 
193
    """Local unit ID"""
 
194
    return os.environ['JUJU_UNIT_NAME']
 
195
 
 
196
 
 
197
def remote_unit():
 
198
    """The remote unit for the current relation hook"""
 
199
    return os.environ.get('JUJU_REMOTE_UNIT', None)
 
200
 
 
201
 
 
202
def service_name():
 
203
    """The name service group this unit belongs to"""
 
204
    return local_unit().split('/')[0]
 
205
 
 
206
 
 
207
@cached
 
208
def remote_service_name(relid=None):
 
209
    """The remote service name for a given relation-id (or the current relation)"""
 
210
    if relid is None:
 
211
        unit = remote_unit()
 
212
    else:
 
213
        units = related_units(relid)
 
214
        unit = units[0] if units else None
 
215
    return unit.split('/')[0] if unit else None
 
216
 
 
217
 
 
218
def hook_name():
 
219
    """The name of the currently executing hook"""
 
220
    return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
 
221
 
 
222
 
 
223
class Config(dict):
 
224
    """A dictionary representation of the charm's config.yaml, with some
 
225
    extra features:
 
226
 
 
227
    - See which values in the dictionary have changed since the previous hook.
 
228
    - For values that have changed, see what the previous value was.
 
229
    - Store arbitrary data for use in a later hook.
 
230
 
 
231
    NOTE: Do not instantiate this object directly - instead call
 
232
    ``hookenv.config()``, which will return an instance of :class:`Config`.
 
233
 
 
234
    Example usage::
 
235
 
 
236
        >>> # inside a hook
 
237
        >>> from charmhelpers.core import hookenv
 
238
        >>> config = hookenv.config()
 
239
        >>> config['foo']
 
240
        'bar'
 
241
        >>> # store a new key/value for later use
 
242
        >>> config['mykey'] = 'myval'
 
243
 
 
244
 
 
245
        >>> # user runs `juju set mycharm foo=baz`
 
246
        >>> # now we're inside subsequent config-changed hook
 
247
        >>> config = hookenv.config()
 
248
        >>> config['foo']
 
249
        'baz'
 
250
        >>> # test to see if this val has changed since last hook
 
251
        >>> config.changed('foo')
 
252
        True
 
253
        >>> # what was the previous value?
 
254
        >>> config.previous('foo')
 
255
        'bar'
 
256
        >>> # keys/values that we add are preserved across hooks
 
257
        >>> config['mykey']
 
258
        'myval'
 
259
 
 
260
    """
 
261
    CONFIG_FILE_NAME = '.juju-persistent-config'
 
262
 
 
263
    def __init__(self, *args, **kw):
 
264
        super(Config, self).__init__(*args, **kw)
 
265
        self.implicit_save = True
 
266
        self._prev_dict = None
 
267
        self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
 
268
        if os.path.exists(self.path):
 
269
            self.load_previous()
 
270
        atexit(self._implicit_save)
 
271
 
 
272
    def load_previous(self, path=None):
 
273
        """Load previous copy of config from disk.
 
274
 
 
275
        In normal usage you don't need to call this method directly - it
 
276
        is called automatically at object initialization.
 
277
 
 
278
        :param path:
 
279
 
 
280
            File path from which to load the previous config. If `None`,
 
281
            config is loaded from the default location. If `path` is
 
282
            specified, subsequent `save()` calls will write to the same
 
283
            path.
 
284
 
 
285
        """
 
286
        self.path = path or self.path
 
287
        with open(self.path) as f:
 
288
            self._prev_dict = json.load(f)
 
289
        for k, v in copy.deepcopy(self._prev_dict).items():
 
290
            if k not in self:
 
291
                self[k] = v
 
292
 
 
293
    def changed(self, key):
 
294
        """Return True if the current value for this key is different from
 
295
        the previous value.
 
296
 
 
297
        """
 
298
        if self._prev_dict is None:
 
299
            return True
 
300
        return self.previous(key) != self.get(key)
 
301
 
 
302
    def previous(self, key):
 
303
        """Return previous value for this key, or None if there
 
304
        is no previous value.
 
305
 
 
306
        """
 
307
        if self._prev_dict:
 
308
            return self._prev_dict.get(key)
 
309
        return None
 
310
 
 
311
    def save(self):
 
312
        """Save this config to disk.
 
313
 
 
314
        If the charm is using the :mod:`Services Framework <services.base>`
 
315
        or :meth:'@hook <Hooks.hook>' decorator, this
 
316
        is called automatically at the end of successful hook execution.
 
317
        Otherwise, it should be called directly by user code.
 
318
 
 
319
        To disable automatic saves, set ``implicit_save=False`` on this
 
320
        instance.
 
321
 
 
322
        """
 
323
        with open(self.path, 'w') as f:
 
324
            json.dump(self, f)
 
325
 
 
326
    def _implicit_save(self):
 
327
        if self.implicit_save:
 
328
            self.save()
 
329
 
 
330
 
 
331
@cached
 
332
def config(scope=None):
 
333
    """Juju charm configuration"""
 
334
    config_cmd_line = ['config-get']
 
335
    if scope is not None:
 
336
        config_cmd_line.append(scope)
 
337
    config_cmd_line.append('--format=json')
 
338
    try:
 
339
        config_data = json.loads(
 
340
            subprocess.check_output(config_cmd_line).decode('UTF-8'))
 
341
        if scope is not None:
 
342
            return config_data
 
343
        return Config(config_data)
 
344
    except ValueError:
 
345
        return None
 
346
 
 
347
 
 
348
@cached
 
349
def relation_get(attribute=None, unit=None, rid=None):
 
350
    """Get relation information"""
 
351
    _args = ['relation-get', '--format=json']
 
352
    if rid:
 
353
        _args.append('-r')
 
354
        _args.append(rid)
 
355
    _args.append(attribute or '-')
 
356
    if unit:
 
357
        _args.append(unit)
 
358
    try:
 
359
        return json.loads(subprocess.check_output(_args).decode('UTF-8'))
 
360
    except ValueError:
 
361
        return None
 
362
    except CalledProcessError as e:
 
363
        if e.returncode == 2:
 
364
            return None
 
365
        raise
 
366
 
 
367
 
 
368
def relation_set(relation_id=None, relation_settings=None, **kwargs):
 
369
    """Set relation information for the current unit"""
 
370
    relation_settings = relation_settings if relation_settings else {}
 
371
    relation_cmd_line = ['relation-set']
 
372
    accepts_file = "--file" in subprocess.check_output(
 
373
        relation_cmd_line + ["--help"], universal_newlines=True)
 
374
    if relation_id is not None:
 
375
        relation_cmd_line.extend(('-r', relation_id))
 
376
    settings = relation_settings.copy()
 
377
    settings.update(kwargs)
 
378
    for key, value in settings.items():
 
379
        # Force value to be a string: it always should, but some call
 
380
        # sites pass in things like dicts or numbers.
 
381
        if value is not None:
 
382
            settings[key] = "{}".format(value)
 
383
    if accepts_file:
 
384
        # --file was introduced in Juju 1.23.2. Use it by default if
 
385
        # available, since otherwise we'll break if the relation data is
 
386
        # too big. Ideally we should tell relation-set to read the data from
 
387
        # stdin, but that feature is broken in 1.23.2: Bug #1454678.
 
388
        with tempfile.NamedTemporaryFile(delete=False) as settings_file:
 
389
            settings_file.write(yaml.safe_dump(settings).encode("utf-8"))
 
390
        subprocess.check_call(
 
391
            relation_cmd_line + ["--file", settings_file.name])
 
392
        os.remove(settings_file.name)
 
393
    else:
 
394
        for key, value in settings.items():
 
395
            if value is None:
 
396
                relation_cmd_line.append('{}='.format(key))
 
397
            else:
 
398
                relation_cmd_line.append('{}={}'.format(key, value))
 
399
        subprocess.check_call(relation_cmd_line)
 
400
    # Flush cache of any relation-gets for local unit
 
401
    flush(local_unit())
 
402
 
 
403
 
 
404
def relation_clear(r_id=None):
 
405
    ''' Clears any relation data already set on relation r_id '''
 
406
    settings = relation_get(rid=r_id,
 
407
                            unit=local_unit())
 
408
    for setting in settings:
 
409
        if setting not in ['public-address', 'private-address']:
 
410
            settings[setting] = None
 
411
    relation_set(relation_id=r_id,
 
412
                 **settings)
 
413
 
 
414
 
 
415
@cached
 
416
def relation_ids(reltype=None):
 
417
    """A list of relation_ids"""
 
418
    reltype = reltype or relation_type()
 
419
    relid_cmd_line = ['relation-ids', '--format=json']
 
420
    if reltype is not None:
 
421
        relid_cmd_line.append(reltype)
 
422
        return json.loads(
 
423
            subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
 
424
    return []
 
425
 
 
426
 
 
427
@cached
 
428
def related_units(relid=None):
 
429
    """A list of related units"""
 
430
    relid = relid or relation_id()
 
431
    units_cmd_line = ['relation-list', '--format=json']
 
432
    if relid is not None:
 
433
        units_cmd_line.extend(('-r', relid))
 
434
    return json.loads(
 
435
        subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
 
436
 
 
437
 
 
438
@cached
 
439
def relation_for_unit(unit=None, rid=None):
 
440
    """Get the json represenation of a unit's relation"""
 
441
    unit = unit or remote_unit()
 
442
    relation = relation_get(unit=unit, rid=rid)
 
443
    for key in relation:
 
444
        if key.endswith('-list'):
 
445
            relation[key] = relation[key].split()
 
446
    relation['__unit__'] = unit
 
447
    return relation
 
448
 
 
449
 
 
450
@cached
 
451
def relations_for_id(relid=None):
 
452
    """Get relations of a specific relation ID"""
 
453
    relation_data = []
 
454
    relid = relid or relation_ids()
 
455
    for unit in related_units(relid):
 
456
        unit_data = relation_for_unit(unit, relid)
 
457
        unit_data['__relid__'] = relid
 
458
        relation_data.append(unit_data)
 
459
    return relation_data
 
460
 
 
461
 
 
462
@cached
 
463
def relations_of_type(reltype=None):
 
464
    """Get relations of a specific type"""
 
465
    relation_data = []
 
466
    reltype = reltype or relation_type()
 
467
    for relid in relation_ids(reltype):
 
468
        for relation in relations_for_id(relid):
 
469
            relation['__relid__'] = relid
 
470
            relation_data.append(relation)
 
471
    return relation_data
 
472
 
 
473
 
 
474
@cached
 
475
def metadata():
 
476
    """Get the current charm metadata.yaml contents as a python object"""
 
477
    with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
 
478
        return yaml.safe_load(md)
 
479
 
 
480
 
 
481
@cached
 
482
def relation_types():
 
483
    """Get a list of relation types supported by this charm"""
 
484
    rel_types = []
 
485
    md = metadata()
 
486
    for key in ('provides', 'requires', 'peers'):
 
487
        section = md.get(key)
 
488
        if section:
 
489
            rel_types.extend(section.keys())
 
490
    return rel_types
 
491
 
 
492
 
 
493
@cached
 
494
def peer_relation_id():
 
495
    '''Get the peers relation id if a peers relation has been joined, else None.'''
 
496
    md = metadata()
 
497
    section = md.get('peers')
 
498
    if section:
 
499
        for key in section:
 
500
            relids = relation_ids(key)
 
501
            if relids:
 
502
                return relids[0]
 
503
    return None
 
504
 
 
505
 
 
506
@cached
 
507
def relation_to_interface(relation_name):
 
508
    """
 
509
    Given the name of a relation, return the interface that relation uses.
 
510
 
 
511
    :returns: The interface name, or ``None``.
 
512
    """
 
513
    return relation_to_role_and_interface(relation_name)[1]
 
514
 
 
515
 
 
516
@cached
 
517
def relation_to_role_and_interface(relation_name):
 
518
    """
 
519
    Given the name of a relation, return the role and the name of the interface
 
520
    that relation uses (where role is one of ``provides``, ``requires``, or ``peers``).
 
521
 
 
522
    :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
 
523
    """
 
524
    _metadata = metadata()
 
525
    for role in ('provides', 'requires', 'peers'):
 
526
        interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
 
527
        if interface:
 
528
            return role, interface
 
529
    return None, None
 
530
 
 
531
 
 
532
@cached
 
533
def role_and_interface_to_relations(role, interface_name):
 
534
    """
 
535
    Given a role and interface name, return a list of relation names for the
 
536
    current charm that use that interface under that role (where role is one
 
537
    of ``provides``, ``requires``, or ``peers``).
 
538
 
 
539
    :returns: A list of relation names.
 
540
    """
 
541
    _metadata = metadata()
 
542
    results = []
 
543
    for relation_name, relation in _metadata.get(role, {}).items():
 
544
        if relation['interface'] == interface_name:
 
545
            results.append(relation_name)
 
546
    return results
 
547
 
 
548
 
 
549
@cached
 
550
def interface_to_relations(interface_name):
 
551
    """
 
552
    Given an interface, return a list of relation names for the current
 
553
    charm that use that interface.
 
554
 
 
555
    :returns: A list of relation names.
 
556
    """
 
557
    results = []
 
558
    for role in ('provides', 'requires', 'peers'):
 
559
        results.extend(role_and_interface_to_relations(role, interface_name))
 
560
    return results
 
561
 
 
562
 
 
563
@cached
 
564
def charm_name():
 
565
    """Get the name of the current charm as is specified on metadata.yaml"""
 
566
    return metadata().get('name')
 
567
 
 
568
 
 
569
@cached
 
570
def relations():
 
571
    """Get a nested dictionary of relation data for all related units"""
 
572
    rels = {}
 
573
    for reltype in relation_types():
 
574
        relids = {}
 
575
        for relid in relation_ids(reltype):
 
576
            units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
 
577
            for unit in related_units(relid):
 
578
                reldata = relation_get(unit=unit, rid=relid)
 
579
                units[unit] = reldata
 
580
            relids[relid] = units
 
581
        rels[reltype] = relids
 
582
    return rels
 
583
 
 
584
 
 
585
@cached
 
586
def is_relation_made(relation, keys='private-address'):
 
587
    '''
 
588
    Determine whether a relation is established by checking for
 
589
    presence of key(s).  If a list of keys is provided, they
 
590
    must all be present for the relation to be identified as made
 
591
    '''
 
592
    if isinstance(keys, str):
 
593
        keys = [keys]
 
594
    for r_id in relation_ids(relation):
 
595
        for unit in related_units(r_id):
 
596
            context = {}
 
597
            for k in keys:
 
598
                context[k] = relation_get(k, rid=r_id,
 
599
                                          unit=unit)
 
600
            if None not in context.values():
 
601
                return True
 
602
    return False
 
603
 
 
604
 
 
605
def open_port(port, protocol="TCP"):
 
606
    """Open a service network port"""
 
607
    _args = ['open-port']
 
608
    _args.append('{}/{}'.format(port, protocol))
 
609
    subprocess.check_call(_args)
 
610
 
 
611
 
 
612
def close_port(port, protocol="TCP"):
 
613
    """Close a service network port"""
 
614
    _args = ['close-port']
 
615
    _args.append('{}/{}'.format(port, protocol))
 
616
    subprocess.check_call(_args)
 
617
 
 
618
 
 
619
@cached
 
620
def unit_get(attribute):
 
621
    """Get the unit ID for the remote unit"""
 
622
    _args = ['unit-get', '--format=json', attribute]
 
623
    try:
 
624
        return json.loads(subprocess.check_output(_args).decode('UTF-8'))
 
625
    except ValueError:
 
626
        return None
 
627
 
 
628
 
 
629
def unit_public_ip():
 
630
    """Get this unit's public IP address"""
 
631
    return unit_get('public-address')
 
632
 
 
633
 
 
634
def unit_private_ip():
 
635
    """Get this unit's private IP address"""
 
636
    return unit_get('private-address')
 
637
 
 
638
 
 
639
@cached
 
640
def storage_get(attribute=None, storage_id=None):
 
641
    """Get storage attributes"""
 
642
    _args = ['storage-get', '--format=json']
 
643
    if storage_id:
 
644
        _args.extend(('-s', storage_id))
 
645
    if attribute:
 
646
        _args.append(attribute)
 
647
    try:
 
648
        return json.loads(subprocess.check_output(_args).decode('UTF-8'))
 
649
    except ValueError:
 
650
        return None
 
651
 
 
652
 
 
653
@cached
 
654
def storage_list(storage_name=None):
 
655
    """List the storage IDs for the unit"""
 
656
    _args = ['storage-list', '--format=json']
 
657
    if storage_name:
 
658
        _args.append(storage_name)
 
659
    try:
 
660
        return json.loads(subprocess.check_output(_args).decode('UTF-8'))
 
661
    except ValueError:
 
662
        return None
 
663
    except OSError as e:
 
664
        import errno
 
665
        if e.errno == errno.ENOENT:
 
666
            # storage-list does not exist
 
667
            return []
 
668
        raise
 
669
 
 
670
 
 
671
class UnregisteredHookError(Exception):
 
672
    """Raised when an undefined hook is called"""
 
673
    pass
 
674
 
 
675
 
 
676
class Hooks(object):
 
677
    """A convenient handler for hook functions.
 
678
 
 
679
    Example::
 
680
 
 
681
        hooks = Hooks()
 
682
 
 
683
        # register a hook, taking its name from the function name
 
684
        @hooks.hook()
 
685
        def install():
 
686
            pass  # your code here
 
687
 
 
688
        # register a hook, providing a custom hook name
 
689
        @hooks.hook("config-changed")
 
690
        def config_changed():
 
691
            pass  # your code here
 
692
 
 
693
        if __name__ == "__main__":
 
694
            # execute a hook based on the name the program is called by
 
695
            hooks.execute(sys.argv)
 
696
    """
 
697
 
 
698
    def __init__(self, config_save=None):
 
699
        super(Hooks, self).__init__()
 
700
        self._hooks = {}
 
701
 
 
702
        # For unknown reasons, we allow the Hooks constructor to override
 
703
        # config().implicit_save.
 
704
        if config_save is not None:
 
705
            config().implicit_save = config_save
 
706
 
 
707
    def register(self, name, function):
 
708
        """Register a hook"""
 
709
        self._hooks[name] = function
 
710
 
 
711
    def execute(self, args):
 
712
        """Execute a registered hook based on args[0]"""
 
713
        _run_atstart()
 
714
        hook_name = os.path.basename(args[0])
 
715
        if hook_name in self._hooks:
 
716
            try:
 
717
                self._hooks[hook_name]()
 
718
            except SystemExit as x:
 
719
                if x.code is None or x.code == 0:
 
720
                    _run_atexit()
 
721
                raise
 
722
            _run_atexit()
 
723
        else:
 
724
            raise UnregisteredHookError(hook_name)
 
725
 
 
726
    def hook(self, *hook_names):
 
727
        """Decorator, registering them as hooks"""
 
728
        def wrapper(decorated):
 
729
            for hook_name in hook_names:
 
730
                self.register(hook_name, decorated)
 
731
            else:
 
732
                self.register(decorated.__name__, decorated)
 
733
                if '_' in decorated.__name__:
 
734
                    self.register(
 
735
                        decorated.__name__.replace('_', '-'), decorated)
 
736
            return decorated
 
737
        return wrapper
 
738
 
 
739
 
 
740
def charm_dir():
 
741
    """Return the root directory of the current charm"""
 
742
    return os.environ.get('CHARM_DIR')
 
743
 
 
744
 
 
745
@cached
 
746
def action_get(key=None):
 
747
    """Gets the value of an action parameter, or all key/value param pairs"""
 
748
    cmd = ['action-get']
 
749
    if key is not None:
 
750
        cmd.append(key)
 
751
    cmd.append('--format=json')
 
752
    action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
 
753
    return action_data
 
754
 
 
755
 
 
756
def action_set(values):
 
757
    """Sets the values to be returned after the action finishes"""
 
758
    cmd = ['action-set']
 
759
    for k, v in list(values.items()):
 
760
        cmd.append('{}={}'.format(k, v))
 
761
    subprocess.check_call(cmd)
 
762
 
 
763
 
 
764
def action_fail(message):
 
765
    """Sets the action status to failed and sets the error message.
 
766
 
 
767
    The results set by action_set are preserved."""
 
768
    subprocess.check_call(['action-fail', message])
 
769
 
 
770
 
 
771
def action_name():
 
772
    """Get the name of the currently executing action."""
 
773
    return os.environ.get('JUJU_ACTION_NAME')
 
774
 
 
775
 
 
776
def action_uuid():
 
777
    """Get the UUID of the currently executing action."""
 
778
    return os.environ.get('JUJU_ACTION_UUID')
 
779
 
 
780
 
 
781
def action_tag():
 
782
    """Get the tag for the currently executing action."""
 
783
    return os.environ.get('JUJU_ACTION_TAG')
 
784
 
 
785
 
 
786
def status_set(workload_state, message):
 
787
    """Set the workload state with a message
 
788
 
 
789
    Use status-set to set the workload state with a message which is visible
 
790
    to the user via juju status. If the status-set command is not found then
 
791
    assume this is juju < 1.23 and juju-log the message unstead.
 
792
 
 
793
    workload_state -- valid juju workload state.
 
794
    message        -- status update message
 
795
    """
 
796
    valid_states = ['maintenance', 'blocked', 'waiting', 'active']
 
797
    if workload_state not in valid_states:
 
798
        raise ValueError(
 
799
            '{!r} is not a valid workload state'.format(workload_state)
 
800
        )
 
801
    cmd = ['status-set', workload_state, message]
 
802
    try:
 
803
        ret = subprocess.call(cmd)
 
804
        if ret == 0:
 
805
            return
 
806
    except OSError as e:
 
807
        if e.errno != errno.ENOENT:
 
808
            raise
 
809
    log_message = 'status-set failed: {} {}'.format(workload_state,
 
810
                                                    message)
 
811
    log(log_message, level='INFO')
 
812
 
 
813
 
 
814
def status_get():
 
815
    """Retrieve the previously set juju workload state and message
 
816
 
 
817
    If the status-get command is not found then assume this is juju < 1.23 and
 
818
    return 'unknown', ""
 
819
 
 
820
    """
 
821
    cmd = ['status-get', "--format=json", "--include-data"]
 
822
    try:
 
823
        raw_status = subprocess.check_output(cmd)
 
824
    except OSError as e:
 
825
        if e.errno == errno.ENOENT:
 
826
            return ('unknown', "")
 
827
        else:
 
828
            raise
 
829
    else:
 
830
        status = json.loads(raw_status.decode("UTF-8"))
 
831
        return (status["status"], status["message"])
 
832
 
 
833
 
 
834
def translate_exc(from_exc, to_exc):
 
835
    def inner_translate_exc1(f):
 
836
        @wraps(f)
 
837
        def inner_translate_exc2(*args, **kwargs):
 
838
            try:
 
839
                return f(*args, **kwargs)
 
840
            except from_exc:
 
841
                raise to_exc
 
842
 
 
843
        return inner_translate_exc2
 
844
 
 
845
    return inner_translate_exc1
 
846
 
 
847
 
 
848
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
849
def is_leader():
 
850
    """Does the current unit hold the juju leadership
 
851
 
 
852
    Uses juju to determine whether the current unit is the leader of its peers
 
853
    """
 
854
    cmd = ['is-leader', '--format=json']
 
855
    return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
 
856
 
 
857
 
 
858
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
859
def leader_get(attribute=None):
 
860
    """Juju leader get value(s)"""
 
861
    cmd = ['leader-get', '--format=json'] + [attribute or '-']
 
862
    return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
 
863
 
 
864
 
 
865
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
866
def leader_set(settings=None, **kwargs):
 
867
    """Juju leader set value(s)"""
 
868
    # Don't log secrets.
 
869
    # log("Juju leader-set '%s'" % (settings), level=DEBUG)
 
870
    cmd = ['leader-set']
 
871
    settings = settings or {}
 
872
    settings.update(kwargs)
 
873
    for k, v in settings.items():
 
874
        if v is None:
 
875
            cmd.append('{}='.format(k))
 
876
        else:
 
877
            cmd.append('{}={}'.format(k, v))
 
878
    subprocess.check_call(cmd)
 
879
 
 
880
 
 
881
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
882
def payload_register(ptype, klass, pid):
 
883
    """ is used while a hook is running to let Juju know that a
 
884
        payload has been started."""
 
885
    cmd = ['payload-register']
 
886
    for x in [ptype, klass, pid]:
 
887
        cmd.append(x)
 
888
    subprocess.check_call(cmd)
 
889
 
 
890
 
 
891
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
892
def payload_unregister(klass, pid):
 
893
    """ is used while a hook is running to let Juju know
 
894
    that a payload has been manually stopped. The <class> and <id> provided
 
895
    must match a payload that has been previously registered with juju using
 
896
    payload-register."""
 
897
    cmd = ['payload-unregister']
 
898
    for x in [klass, pid]:
 
899
        cmd.append(x)
 
900
    subprocess.check_call(cmd)
 
901
 
 
902
 
 
903
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
 
904
def payload_status_set(klass, pid, status):
 
905
    """is used to update the current status of a registered payload.
 
906
    The <class> and <id> provided must match a payload that has been previously
 
907
    registered with juju using payload-register. The <status> must be one of the
 
908
    follow: starting, started, stopping, stopped"""
 
909
    cmd = ['payload-status-set']
 
910
    for x in [klass, pid, status]:
 
911
        cmd.append(x)
 
912
    subprocess.check_call(cmd)
 
913
 
 
914
 
 
915
@cached
 
916
def juju_version():
 
917
    """Full version string (eg. '1.23.3.1-trusty-amd64')"""
 
918
    # Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
 
919
    jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
 
920
    return subprocess.check_output([jujud, 'version'],
 
921
                                   universal_newlines=True).strip()
 
922
 
 
923
 
 
924
@cached
 
925
def has_juju_version(minimum_version):
 
926
    """Return True if the Juju version is at least the provided version"""
 
927
    return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
 
928
 
 
929
 
 
930
_atexit = []
 
931
_atstart = []
 
932
 
 
933
 
 
934
def atstart(callback, *args, **kwargs):
 
935
    '''Schedule a callback to run before the main hook.
 
936
 
 
937
    Callbacks are run in the order they were added.
 
938
 
 
939
    This is useful for modules and classes to perform initialization
 
940
    and inject behavior. In particular:
 
941
 
 
942
        - Run common code before all of your hooks, such as logging
 
943
          the hook name or interesting relation data.
 
944
        - Defer object or module initialization that requires a hook
 
945
          context until we know there actually is a hook context,
 
946
          making testing easier.
 
947
        - Rather than requiring charm authors to include boilerplate to
 
948
          invoke your helper's behavior, have it run automatically if
 
949
          your object is instantiated or module imported.
 
950
 
 
951
    This is not at all useful after your hook framework as been launched.
 
952
    '''
 
953
    global _atstart
 
954
    _atstart.append((callback, args, kwargs))
 
955
 
 
956
 
 
957
def atexit(callback, *args, **kwargs):
 
958
    '''Schedule a callback to run on successful hook completion.
 
959
 
 
960
    Callbacks are run in the reverse order that they were added.'''
 
961
    _atexit.append((callback, args, kwargs))
 
962
 
 
963
 
 
964
def _run_atstart():
 
965
    '''Hook frameworks must invoke this before running the main hook body.'''
 
966
    global _atstart
 
967
    for callback, args, kwargs in _atstart:
 
968
        callback(*args, **kwargs)
 
969
    del _atstart[:]
 
970
 
 
971
 
 
972
def _run_atexit():
 
973
    '''Hook frameworks must invoke this after the main hook body has
 
974
    successfully completed. Do not invoke it if the hook fails.'''
 
975
    global _atexit
 
976
    for callback, args, kwargs in reversed(_atexit):
 
977
        callback(*args, **kwargs)
 
978
    del _atexit[:]