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 peer_relation_id():
495
'''Get the peers relation id if a peers relation has been joined, else None.'''
497
section = md.get('peers')
500
relids = relation_ids(key)
507
def relation_to_interface(relation_name):
509
Given the name of a relation, return the interface that relation uses.
511
:returns: The interface name, or ``None``.
513
return relation_to_role_and_interface(relation_name)[1]
517
def relation_to_role_and_interface(relation_name):
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``).
522
:returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
524
_metadata = metadata()
525
for role in ('provides', 'requires', 'peers'):
526
interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
528
return role, interface
533
def role_and_interface_to_relations(role, interface_name):
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``).
539
:returns: A list of relation names.
541
_metadata = metadata()
543
for relation_name, relation in _metadata.get(role, {}).items():
544
if relation['interface'] == interface_name:
545
results.append(relation_name)
550
def interface_to_relations(interface_name):
552
Given an interface, return a list of relation names for the current
553
charm that use that interface.
555
:returns: A list of relation names.
558
for role in ('provides', 'requires', 'peers'):
559
results.extend(role_and_interface_to_relations(role, interface_name))
565
"""Get the name of the current charm as is specified on metadata.yaml"""
566
return metadata().get('name')
571
"""Get a nested dictionary of relation data for all related units"""
573
for reltype in relation_types():
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
586
def is_relation_made(relation, keys='private-address'):
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
592
if isinstance(keys, str):
594
for r_id in relation_ids(relation):
595
for unit in related_units(r_id):
598
context[k] = relation_get(k, rid=r_id,
600
if None not in context.values():
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)
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)
620
def unit_get(attribute):
621
"""Get the unit ID for the remote unit"""
622
_args = ['unit-get', '--format=json', attribute]
624
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
629
def unit_public_ip():
630
"""Get this unit's public IP address"""
631
return unit_get('public-address')
634
def unit_private_ip():
635
"""Get this unit's private IP address"""
636
return unit_get('private-address')
640
def storage_get(attribute=None, storage_id=None):
641
"""Get storage attributes"""
642
_args = ['storage-get', '--format=json']
644
_args.extend(('-s', storage_id))
646
_args.append(attribute)
648
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
654
def storage_list(storage_name=None):
655
"""List the storage IDs for the unit"""
656
_args = ['storage-list', '--format=json']
658
_args.append(storage_name)
660
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
665
if e.errno == errno.ENOENT:
666
# storage-list does not exist
671
class UnregisteredHookError(Exception):
672
"""Raised when an undefined hook is called"""
677
"""A convenient handler for hook functions.
683
# register a hook, taking its name from the function name
686
pass # your code here
688
# register a hook, providing a custom hook name
689
@hooks.hook("config-changed")
690
def config_changed():
691
pass # your code here
693
if __name__ == "__main__":
694
# execute a hook based on the name the program is called by
695
hooks.execute(sys.argv)
698
def __init__(self, config_save=None):
699
super(Hooks, self).__init__()
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
707
def register(self, name, function):
708
"""Register a hook"""
709
self._hooks[name] = function
711
def execute(self, args):
712
"""Execute a registered hook based on args[0]"""
714
hook_name = os.path.basename(args[0])
715
if hook_name in self._hooks:
717
self._hooks[hook_name]()
718
except SystemExit as x:
719
if x.code is None or x.code == 0:
724
raise UnregisteredHookError(hook_name)
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)
732
self.register(decorated.__name__, decorated)
733
if '_' in decorated.__name__:
735
decorated.__name__.replace('_', '-'), decorated)
741
"""Return the root directory of the current charm"""
742
return os.environ.get('CHARM_DIR')
746
def action_get(key=None):
747
"""Gets the value of an action parameter, or all key/value param pairs"""
751
cmd.append('--format=json')
752
action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
756
def action_set(values):
757
"""Sets the values to be returned after the action finishes"""
759
for k, v in list(values.items()):
760
cmd.append('{}={}'.format(k, v))
761
subprocess.check_call(cmd)
764
def action_fail(message):
765
"""Sets the action status to failed and sets the error message.
767
The results set by action_set are preserved."""
768
subprocess.check_call(['action-fail', message])
772
"""Get the name of the currently executing action."""
773
return os.environ.get('JUJU_ACTION_NAME')
777
"""Get the UUID of the currently executing action."""
778
return os.environ.get('JUJU_ACTION_UUID')
782
"""Get the tag for the currently executing action."""
783
return os.environ.get('JUJU_ACTION_TAG')
786
def status_set(workload_state, message):
787
"""Set the workload state with a message
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.
793
workload_state -- valid juju workload state.
794
message -- status update message
796
valid_states = ['maintenance', 'blocked', 'waiting', 'active']
797
if workload_state not in valid_states:
799
'{!r} is not a valid workload state'.format(workload_state)
801
cmd = ['status-set', workload_state, message]
803
ret = subprocess.call(cmd)
807
if e.errno != errno.ENOENT:
809
log_message = 'status-set failed: {} {}'.format(workload_state,
811
log(log_message, level='INFO')
815
"""Retrieve the previously set juju workload state and message
817
If the status-get command is not found then assume this is juju < 1.23 and
821
cmd = ['status-get', "--format=json", "--include-data"]
823
raw_status = subprocess.check_output(cmd)
825
if e.errno == errno.ENOENT:
826
return ('unknown', "")
830
status = json.loads(raw_status.decode("UTF-8"))
831
return (status["status"], status["message"])
834
def translate_exc(from_exc, to_exc):
835
def inner_translate_exc1(f):
837
def inner_translate_exc2(*args, **kwargs):
839
return f(*args, **kwargs)
843
return inner_translate_exc2
845
return inner_translate_exc1
848
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
850
"""Does the current unit hold the juju leadership
852
Uses juju to determine whether the current unit is the leader of its peers
854
cmd = ['is-leader', '--format=json']
855
return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
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'))
865
@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
866
def leader_set(settings=None, **kwargs):
867
"""Juju leader set value(s)"""
869
# log("Juju leader-set '%s'" % (settings), level=DEBUG)
871
settings = settings or {}
872
settings.update(kwargs)
873
for k, v in settings.items():
875
cmd.append('{}='.format(k))
877
cmd.append('{}={}'.format(k, v))
878
subprocess.check_call(cmd)
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]:
888
subprocess.check_call(cmd)
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
897
cmd = ['payload-unregister']
898
for x in [klass, pid]:
900
subprocess.check_call(cmd)
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]:
912
subprocess.check_call(cmd)
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()
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)
934
def atstart(callback, *args, **kwargs):
935
'''Schedule a callback to run before the main hook.
937
Callbacks are run in the order they were added.
939
This is useful for modules and classes to perform initialization
940
and inject behavior. In particular:
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.
951
This is not at all useful after your hook framework as been launched.
954
_atstart.append((callback, args, kwargs))
957
def atexit(callback, *args, **kwargs):
958
'''Schedule a callback to run on successful hook completion.
960
Callbacks are run in the reverse order that they were added.'''
961
_atexit.append((callback, args, kwargs))
965
'''Hook frameworks must invoke this before running the main hook body.'''
967
for callback, args, kwargs in _atstart:
968
callback(*args, **kwargs)
973
'''Hook frameworks must invoke this after the main hook body has
974
successfully completed. Do not invoke it if the hook fails.'''
976
for callback, args, kwargs in reversed(_atexit):
977
callback(*args, **kwargs)