1
# Copyright 2014-2015 Canonical Limited.
3
# This file is part of charm-helpers.
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.
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.
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/>.
17
"Interactions with the Juju environment"
18
# Copyright 2013 Canonical Ltd.
21
# Charm Helpers Developers <juju@lists.ubuntu.com>
23
from __future__ import print_function
25
from distutils.version import LooseVersion
26
from functools import wraps
35
from subprocess import CalledProcessError
39
from UserDict import UserDict
41
from collections import UserDict
54
"""Cache return values for multiple executions of func + args
59
def unit_get(attribute):
64
will cache the result of unit_get + 'test' for future calls.
67
def wrapper(*args, **kwargs):
69
key = str((func, args, kwargs))
73
pass # Drop out of the exception handler scope.
74
res = func(*args, **kwargs)
77
wrapper._wrapped = func
82
"""Flushes any entries from function cache where the
83
key is found in the function+args """
87
flush_list.append(item)
88
for item in flush_list:
92
def log(message, level=None):
93
"""Write a message to the juju log"""
94
command = ['juju-log']
96
command += ['-l', level]
97
if not isinstance(message, six.string_types):
98
message = repr(message)
100
# Missing juju-log should not cause failures in unit tests
101
# Send log output to stderr
103
subprocess.call(command)
105
if e.errno == errno.ENOENT:
107
message = "{}: {}".format(level, message)
108
message = "juju-log: {}".format(message)
109
print(message, file=sys.stderr)
114
class Serializable(UserDict):
115
"""Wrapper, an object that can be serialized to yaml or json"""
117
def __init__(self, obj):
119
UserDict.__init__(self)
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:
130
# Proxy to the wrapped object via dict interface.
132
return self.data[attr]
134
raise AttributeError(attr)
136
def __getstate__(self):
137
# Pickle as a standard dictionary.
140
def __setstate__(self, state):
141
# Unpickle into our wrapper.
145
"""Serialize the object to json"""
146
return json.dumps(self.data)
149
"""Serialize the object to yaml"""
150
return yaml.dump(self.data)
153
def execution_environment():
154
"""A convenient bundling of the current execution context"""
156
context['conf'] = config()
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
167
def in_relation_hook():
168
"""Determine whether we're running in a relation hook"""
169
return 'JUJU_RELATION' in os.environ
173
"""The scope for the current relation hook"""
174
return os.environ.get('JUJU_RELATION', None)
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:
189
raise ValueError('Must specify neither or both of relation_name and service_or_unit')
194
return os.environ['JUJU_UNIT_NAME']
198
"""The remote unit for the current relation hook"""
199
return os.environ.get('JUJU_REMOTE_UNIT', None)
203
"""The name service group this unit belongs to"""
204
return local_unit().split('/')[0]
208
def remote_service_name(relid=None):
209
"""The remote service name for a given relation-id (or the current relation)"""
213
units = related_units(relid)
214
unit = units[0] if units else None
215
return unit.split('/')[0] if unit else None
219
"""The name of the currently executing hook"""
220
return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
224
"""A dictionary representation of the charm's config.yaml, with some
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.
231
NOTE: Do not instantiate this object directly - instead call
232
``hookenv.config()``, which will return an instance of :class:`Config`.
237
>>> from charmhelpers.core import hookenv
238
>>> config = hookenv.config()
241
>>> # store a new key/value for later use
242
>>> config['mykey'] = 'myval'
245
>>> # user runs `juju set mycharm foo=baz`
246
>>> # now we're inside subsequent config-changed hook
247
>>> config = hookenv.config()
250
>>> # test to see if this val has changed since last hook
251
>>> config.changed('foo')
253
>>> # what was the previous value?
254
>>> config.previous('foo')
256
>>> # keys/values that we add are preserved across hooks
261
CONFIG_FILE_NAME = '.juju-persistent-config'
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):
270
atexit(self._implicit_save)
272
def load_previous(self, path=None):
273
"""Load previous copy of config from disk.
275
In normal usage you don't need to call this method directly - it
276
is called automatically at object initialization.
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
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():
293
def changed(self, key):
294
"""Return True if the current value for this key is different from
298
if self._prev_dict is None:
300
return self.previous(key) != self.get(key)
302
def previous(self, key):
303
"""Return previous value for this key, or None if there
304
is no previous value.
308
return self._prev_dict.get(key)
312
"""Save this config to disk.
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.
319
To disable automatic saves, set ``implicit_save=False`` on this
323
with open(self.path, 'w') as f:
326
def _implicit_save(self):
327
if self.implicit_save:
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')
339
config_data = json.loads(
340
subprocess.check_output(config_cmd_line).decode('UTF-8'))
341
if scope is not None:
343
return Config(config_data)
349
def relation_get(attribute=None, unit=None, rid=None):
350
"""Get relation information"""
351
_args = ['relation-get', '--format=json']
355
_args.append(attribute or '-')
359
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
362
except CalledProcessError as e:
363
if e.returncode == 2:
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)
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)
394
for key, value in settings.items():
396
relation_cmd_line.append('{}='.format(key))
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
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,
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,
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)
423
subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
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))
435
subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
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)
444
if key.endswith('-list'):
445
relation[key] = relation[key].split()
446
relation['__unit__'] = unit
451
def relations_for_id(relid=None):
452
"""Get relations of a specific relation ID"""
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)
463
def relations_of_type(reltype=None):
464
"""Get relations of a specific type"""
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)
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)
482
def relation_types():
483
"""Get a list of relation types supported by this charm"""
486
for key in ('provides', 'requires', 'peers'):
487
section = md.get(key)
489
rel_types.extend(section.keys())
494
def relation_to_interface(relation_name):
496
Given the name of a relation, return the interface that relation uses.
498
:returns: The interface name, or ``None``.
500
return relation_to_role_and_interface(relation_name)[1]
504
def relation_to_role_and_interface(relation_name):
506
Given the name of a relation, return the role and the name of the interface
507
that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
509
:returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
511
_metadata = metadata()
512
for role in ('provides', 'requires', 'peer'):
513
interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
515
return role, interface
520
def role_and_interface_to_relations(role, interface_name):
522
Given a role and interface name, return a list of relation names for the
523
current charm that use that interface under that role (where role is one
524
of ``provides``, ``requires``, or ``peer``).
526
:returns: A list of relation names.
528
_metadata = metadata()
530
for relation_name, relation in _metadata.get(role, {}).items():
531
if relation['interface'] == interface_name:
532
results.append(relation_name)
537
def interface_to_relations(interface_name):
539
Given an interface, return a list of relation names for the current
540
charm that use that interface.
542
:returns: A list of relation names.
545
for role in ('provides', 'requires', 'peer'):
546
results.extend(role_and_interface_to_relations(role, interface_name))
552
"""Get the name of the current charm as is specified on metadata.yaml"""
553
return metadata().get('name')
558
"""Get a nested dictionary of relation data for all related units"""
560
for reltype in relation_types():
562
for relid in relation_ids(reltype):
563
units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
564
for unit in related_units(relid):
565
reldata = relation_get(unit=unit, rid=relid)
566
units[unit] = reldata
567
relids[relid] = units
568
rels[reltype] = relids
573
def is_relation_made(relation, keys='private-address'):
575
Determine whether a relation is established by checking for
576
presence of key(s). If a list of keys is provided, they
577
must all be present for the relation to be identified as made
579
if isinstance(keys, str):
581
for r_id in relation_ids(relation):
582
for unit in related_units(r_id):
585
context[k] = relation_get(k, rid=r_id,
587
if None not in context.values():
592
def open_port(port, protocol="TCP"):
593
"""Open a service network port"""
594
_args = ['open-port']
595
_args.append('{}/{}'.format(port, protocol))
596
subprocess.check_call(_args)
599
def close_port(port, protocol="TCP"):
600
"""Close a service network port"""
601
_args = ['close-port']
602
_args.append('{}/{}'.format(port, protocol))
603
subprocess.check_call(_args)
607
def unit_get(attribute):
608
"""Get the unit ID for the remote unit"""
609
_args = ['unit-get', '--format=json', attribute]
611
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
616
def unit_public_ip():
617
"""Get this unit's public IP address"""
618
return unit_get('public-address')
621
def unit_private_ip():
622
"""Get this unit's private IP address"""
623
return unit_get('private-address')
626
class UnregisteredHookError(Exception):
627
"""Raised when an undefined hook is called"""
632
"""A convenient handler for hook functions.
638
# register a hook, taking its name from the function name
641
pass # your code here
643
# register a hook, providing a custom hook name
644
@hooks.hook("config-changed")
645
def config_changed():
646
pass # your code here
648
if __name__ == "__main__":
649
# execute a hook based on the name the program is called by
650
hooks.execute(sys.argv)
653
def __init__(self, config_save=None):
654
super(Hooks, self).__init__()
657
# For unknown reasons, we allow the Hooks constructor to override
658
# config().implicit_save.
659
if config_save is not None:
660
config().implicit_save = config_save
662
def register(self, name, function):
663
"""Register a hook"""
664
self._hooks[name] = function
666
def execute(self, args):
667
"""Execute a registered hook based on args[0]"""
669
hook_name = os.path.basename(args[0])
670
if hook_name in self._hooks:
672
self._hooks[hook_name]()
673
except SystemExit as x:
674
if x.code is None or x.code == 0:
679
raise UnregisteredHookError(hook_name)
681
def hook(self, *hook_names):
682
"""Decorator, registering them as hooks"""
683
def wrapper(decorated):
684
for hook_name in hook_names:
685
self.register(hook_name, decorated)
687
self.register(decorated.__name__, decorated)
688
if '_' in decorated.__name__:
690
decorated.__name__.replace('_', '-'), decorated)
696
"""Return the root directory of the current charm"""
697
return os.environ.get('CHARM_DIR')
701
def action_get(key=None):
702
"""Gets the value of an action parameter, or all key/value param pairs"""
706
cmd.append('--format=json')
707
action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
711
def action_set(values):
712
"""Sets the values to be returned after the action finishes"""
714
for k, v in list(values.items()):
715
cmd.append('{}={}'.format(k, v))
716
subprocess.check_call(cmd)
719
def action_fail(message):
720
"""Sets the action status to failed and sets the error message.
722
The results set by action_set are preserved."""
723
subprocess.check_call(['action-fail', message])
727
"""Get the name of the currently executing action."""
728
return os.environ.get('JUJU_ACTION_NAME')
732
"""Get the UUID of the currently executing action."""
733
return os.environ.get('JUJU_ACTION_UUID')
737
"""Get the tag for the currently executing action."""
738
return os.environ.get('JUJU_ACTION_TAG')
741
def status_set(workload_state, message):
742
"""Set the workload state with a message
744
Use status-set to set the workload state with a message which is visible
745
to the user via juju status. If the status-set command is not found then
746
assume this is juju < 1.23 and juju-log the message unstead.
748
workload_state -- valid juju workload state.
749
message -- status update message
751
valid_states = ['maintenance', 'blocked', 'waiting', 'active']
752
if workload_state not in valid_states:
754
'{!r} is not a valid workload state'.format(workload_state)
756
cmd = ['status-set', workload_state, message]
758
ret = subprocess.call(cmd)
762
if e.errno != errno.ENOENT:
764
log_message = 'status-set failed: {} {}'.format(workload_state,
766
log(log_message, level='INFO')
770
"""Retrieve the previously set juju workload state and message
772
If the status-get command is not found then assume this is juju < 1.23 and
776
cmd = ['status-get', "--format=json", "--include-data"]
778
raw_status = subprocess.check_output(cmd)
780
if e.errno == errno.ENOENT:
781
return ('unknown', "")
785
status = json.loads(raw_status.decode("UTF-8"))
786
return (status["status"], status["message"])
789
def translate_exc(from_exc, to_exc):
790
def inner_translate_exc1(f):
791
def inner_translate_exc2(*args, **kwargs):
793
return f(*args, **kwargs)
797
return inner_translate_exc2
799
return inner_translate_exc1
802
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
804
"""Does the current unit hold the juju leadership
806
Uses juju to determine whether the current unit is the leader of its peers
808
cmd = ['is-leader', '--format=json']
809
return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
812
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
813
def leader_get(attribute=None):
814
"""Juju leader get value(s)"""
815
cmd = ['leader-get', '--format=json'] + [attribute or '-']
816
return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
819
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
820
def leader_set(settings=None, **kwargs):
821
"""Juju leader set value(s)"""
823
# log("Juju leader-set '%s'" % (settings), level=DEBUG)
825
settings = settings or {}
826
settings.update(kwargs)
827
for k, v in settings.items():
829
cmd.append('{}='.format(k))
831
cmd.append('{}={}'.format(k, v))
832
subprocess.check_call(cmd)
837
"""Full version string (eg. '1.23.3.1-trusty-amd64')"""
838
# Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
839
jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
840
return subprocess.check_output([jujud, 'version'],
841
universal_newlines=True).strip()
845
def has_juju_version(minimum_version):
846
"""Return True if the Juju version is at least the provided version"""
847
return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
854
def atstart(callback, *args, **kwargs):
855
'''Schedule a callback to run before the main hook.
857
Callbacks are run in the order they were added.
859
This is useful for modules and classes to perform initialization
860
and inject behavior. In particular:
862
- Run common code before all of your hooks, such as logging
863
the hook name or interesting relation data.
864
- Defer object or module initialization that requires a hook
865
context until we know there actually is a hook context,
866
making testing easier.
867
- Rather than requiring charm authors to include boilerplate to
868
invoke your helper's behavior, have it run automatically if
869
your object is instantiated or module imported.
871
This is not at all useful after your hook framework as been launched.
874
_atstart.append((callback, args, kwargs))
877
def atexit(callback, *args, **kwargs):
878
'''Schedule a callback to run on successful hook completion.
880
Callbacks are run in the reverse order that they were added.'''
881
_atexit.append((callback, args, kwargs))
885
'''Hook frameworks must invoke this before running the main hook body.'''
887
for callback, args, kwargs in _atstart:
888
callback(*args, **kwargs)
893
'''Hook frameworks must invoke this after the main hook body has
894
successfully completed. Do not invoke it if the hook fails.'''
896
for callback, args, kwargs in reversed(_atexit):
897
callback(*args, **kwargs)