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
24
from functools import wraps
31
from subprocess import CalledProcessError
35
from UserDict import UserDict
37
from collections import UserDict
50
"""Cache return values for multiple executions of func + args
55
def unit_get(attribute):
60
will cache the result of unit_get + 'test' for future calls.
63
def wrapper(*args, **kwargs):
65
key = str((func, args, kwargs))
69
pass # Drop out of the exception handler scope.
70
res = func(*args, **kwargs)
77
"""Flushes any entries from function cache where the
78
key is found in the function+args """
82
flush_list.append(item)
83
for item in flush_list:
87
def log(message, level=None):
88
"""Write a message to the juju log"""
89
command = ['juju-log']
91
command += ['-l', level]
92
if not isinstance(message, six.string_types):
93
message = repr(message)
95
# Missing juju-log should not cause failures in unit tests
96
# Send log output to stderr
98
subprocess.call(command)
100
if e.errno == errno.ENOENT:
102
message = "{}: {}".format(level, message)
103
message = "juju-log: {}".format(message)
104
print(message, file=sys.stderr)
109
class Serializable(UserDict):
110
"""Wrapper, an object that can be serialized to yaml or json"""
112
def __init__(self, obj):
114
UserDict.__init__(self)
117
def __getattr__(self, attr):
118
# See if this object has attribute.
119
if attr in ("json", "yaml", "data"):
120
return self.__dict__[attr]
121
# Check for attribute in wrapped object.
122
got = getattr(self.data, attr, MARKER)
123
if got is not MARKER:
125
# Proxy to the wrapped object via dict interface.
127
return self.data[attr]
129
raise AttributeError(attr)
131
def __getstate__(self):
132
# Pickle as a standard dictionary.
135
def __setstate__(self, state):
136
# Unpickle into our wrapper.
140
"""Serialize the object to json"""
141
return json.dumps(self.data)
144
"""Serialize the object to yaml"""
145
return yaml.dump(self.data)
148
def execution_environment():
149
"""A convenient bundling of the current execution context"""
151
context['conf'] = config()
153
context['reltype'] = relation_type()
154
context['relid'] = relation_id()
155
context['rel'] = relation_get()
156
context['unit'] = local_unit()
157
context['rels'] = relations()
158
context['env'] = os.environ
162
def in_relation_hook():
163
"""Determine whether we're running in a relation hook"""
164
return 'JUJU_RELATION' in os.environ
168
"""The scope for the current relation hook"""
169
return os.environ.get('JUJU_RELATION', None)
173
"""The relation ID for the current relation hook"""
174
return os.environ.get('JUJU_RELATION_ID', None)
179
return os.environ['JUJU_UNIT_NAME']
183
"""The remote unit for the current relation hook"""
184
return os.environ.get('JUJU_REMOTE_UNIT', None)
188
"""The name service group this unit belongs to"""
189
return local_unit().split('/')[0]
193
"""The name of the currently executing hook"""
194
return os.path.basename(sys.argv[0])
198
"""A dictionary representation of the charm's config.yaml, with some
201
- See which values in the dictionary have changed since the previous hook.
202
- For values that have changed, see what the previous value was.
203
- Store arbitrary data for use in a later hook.
205
NOTE: Do not instantiate this object directly - instead call
206
``hookenv.config()``, which will return an instance of :class:`Config`.
211
>>> from charmhelpers.core import hookenv
212
>>> config = hookenv.config()
215
>>> # store a new key/value for later use
216
>>> config['mykey'] = 'myval'
219
>>> # user runs `juju set mycharm foo=baz`
220
>>> # now we're inside subsequent config-changed hook
221
>>> config = hookenv.config()
224
>>> # test to see if this val has changed since last hook
225
>>> config.changed('foo')
227
>>> # what was the previous value?
228
>>> config.previous('foo')
230
>>> # keys/values that we add are preserved across hooks
235
CONFIG_FILE_NAME = '.juju-persistent-config'
237
def __init__(self, *args, **kw):
238
super(Config, self).__init__(*args, **kw)
239
self.implicit_save = True
240
self._prev_dict = None
241
self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
242
if os.path.exists(self.path):
245
def __getitem__(self, key):
246
"""For regular dict lookups, check the current juju config first,
247
then the previous (saved) copy. This ensures that user-saved values
248
will be returned by a dict lookup.
252
return dict.__getitem__(self, key)
254
return (self._prev_dict or {})[key]
256
def get(self, key, default=None):
264
if self._prev_dict is not None:
265
prev_keys = self._prev_dict.keys()
266
return list(set(prev_keys + list(dict.keys(self))))
268
def load_previous(self, path=None):
269
"""Load previous copy of config from disk.
271
In normal usage you don't need to call this method directly - it
272
is called automatically at object initialization.
276
File path from which to load the previous config. If `None`,
277
config is loaded from the default location. If `path` is
278
specified, subsequent `save()` calls will write to the same
282
self.path = path or self.path
283
with open(self.path) as f:
284
self._prev_dict = json.load(f)
286
def changed(self, key):
287
"""Return True if the current value for this key is different from
291
if self._prev_dict is None:
293
return self.previous(key) != self.get(key)
295
def previous(self, key):
296
"""Return previous value for this key, or None if there
297
is no previous value.
301
return self._prev_dict.get(key)
305
"""Save this config to disk.
307
If the charm is using the :mod:`Services Framework <services.base>`
308
or :meth:'@hook <Hooks.hook>' decorator, this
309
is called automatically at the end of successful hook execution.
310
Otherwise, it should be called directly by user code.
312
To disable automatic saves, set ``implicit_save=False`` on this
317
for k, v in six.iteritems(self._prev_dict):
320
with open(self.path, 'w') as f:
325
def config(scope=None):
326
"""Juju charm configuration"""
327
config_cmd_line = ['config-get']
328
if scope is not None:
329
config_cmd_line.append(scope)
330
config_cmd_line.append('--format=json')
332
config_data = json.loads(
333
subprocess.check_output(config_cmd_line).decode('UTF-8'))
334
if scope is not None:
336
return Config(config_data)
342
def relation_get(attribute=None, unit=None, rid=None):
343
"""Get relation information"""
344
_args = ['relation-get', '--format=json']
348
_args.append(attribute or '-')
352
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
355
except CalledProcessError as e:
356
if e.returncode == 2:
361
def relation_set(relation_id=None, relation_settings=None, **kwargs):
362
"""Set relation information for the current unit"""
363
relation_settings = relation_settings if relation_settings else {}
364
relation_cmd_line = ['relation-set']
365
if relation_id is not None:
366
relation_cmd_line.extend(('-r', relation_id))
367
for k, v in (list(relation_settings.items()) + list(kwargs.items())):
369
relation_cmd_line.append('{}='.format(k))
371
relation_cmd_line.append('{}={}'.format(k, v))
372
subprocess.check_call(relation_cmd_line)
373
# Flush cache of any relation-gets for local unit
378
def relation_ids(reltype=None):
379
"""A list of relation_ids"""
380
reltype = reltype or relation_type()
381
relid_cmd_line = ['relation-ids', '--format=json']
382
if reltype is not None:
383
relid_cmd_line.append(reltype)
385
subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
390
def related_units(relid=None):
391
"""A list of related units"""
392
relid = relid or relation_id()
393
units_cmd_line = ['relation-list', '--format=json']
394
if relid is not None:
395
units_cmd_line.extend(('-r', relid))
397
subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
401
def relation_for_unit(unit=None, rid=None):
402
"""Get the json represenation of a unit's relation"""
403
unit = unit or remote_unit()
404
relation = relation_get(unit=unit, rid=rid)
406
if key.endswith('-list'):
407
relation[key] = relation[key].split()
408
relation['__unit__'] = unit
413
def relations_for_id(relid=None):
414
"""Get relations of a specific relation ID"""
416
relid = relid or relation_ids()
417
for unit in related_units(relid):
418
unit_data = relation_for_unit(unit, relid)
419
unit_data['__relid__'] = relid
420
relation_data.append(unit_data)
425
def relations_of_type(reltype=None):
426
"""Get relations of a specific type"""
428
reltype = reltype or relation_type()
429
for relid in relation_ids(reltype):
430
for relation in relations_for_id(relid):
431
relation['__relid__'] = relid
432
relation_data.append(relation)
438
"""Get the current charm metadata.yaml contents as a python object"""
439
with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
440
return yaml.safe_load(md)
444
def relation_types():
445
"""Get a list of relation types supported by this charm"""
448
for key in ('provides', 'requires', 'peers'):
449
section = md.get(key)
451
rel_types.extend(section.keys())
457
"""Get the name of the current charm as is specified on metadata.yaml"""
458
return metadata().get('name')
463
"""Get a nested dictionary of relation data for all related units"""
465
for reltype in relation_types():
467
for relid in relation_ids(reltype):
468
units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
469
for unit in related_units(relid):
470
reldata = relation_get(unit=unit, rid=relid)
471
units[unit] = reldata
472
relids[relid] = units
473
rels[reltype] = relids
478
def is_relation_made(relation, keys='private-address'):
480
Determine whether a relation is established by checking for
481
presence of key(s). If a list of keys is provided, they
482
must all be present for the relation to be identified as made
484
if isinstance(keys, str):
486
for r_id in relation_ids(relation):
487
for unit in related_units(r_id):
490
context[k] = relation_get(k, rid=r_id,
492
if None not in context.values():
497
def open_port(port, protocol="TCP"):
498
"""Open a service network port"""
499
_args = ['open-port']
500
_args.append('{}/{}'.format(port, protocol))
501
subprocess.check_call(_args)
504
def close_port(port, protocol="TCP"):
505
"""Close a service network port"""
506
_args = ['close-port']
507
_args.append('{}/{}'.format(port, protocol))
508
subprocess.check_call(_args)
512
def unit_get(attribute):
513
"""Get the unit ID for the remote unit"""
514
_args = ['unit-get', '--format=json', attribute]
516
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
521
def unit_public_ip():
522
"""Get this unit's public IP address"""
523
return unit_get('public-address')
526
def unit_private_ip():
527
"""Get this unit's private IP address"""
528
return unit_get('private-address')
531
class UnregisteredHookError(Exception):
532
"""Raised when an undefined hook is called"""
537
"""A convenient handler for hook functions.
543
# register a hook, taking its name from the function name
546
pass # your code here
548
# register a hook, providing a custom hook name
549
@hooks.hook("config-changed")
550
def config_changed():
551
pass # your code here
553
if __name__ == "__main__":
554
# execute a hook based on the name the program is called by
555
hooks.execute(sys.argv)
558
def __init__(self, config_save=True):
559
super(Hooks, self).__init__()
561
self._config_save = config_save
563
def register(self, name, function):
564
"""Register a hook"""
565
self._hooks[name] = function
567
def execute(self, args):
568
"""Execute a registered hook based on args[0]"""
569
hook_name = os.path.basename(args[0])
570
if hook_name in self._hooks:
571
self._hooks[hook_name]()
572
if self._config_save:
574
if cfg.implicit_save:
577
raise UnregisteredHookError(hook_name)
579
def hook(self, *hook_names):
580
"""Decorator, registering them as hooks"""
581
def wrapper(decorated):
582
for hook_name in hook_names:
583
self.register(hook_name, decorated)
585
self.register(decorated.__name__, decorated)
586
if '_' in decorated.__name__:
588
decorated.__name__.replace('_', '-'), decorated)
594
"""Return the root directory of the current charm"""
595
return os.environ.get('CHARM_DIR')
599
def action_get(key=None):
600
"""Gets the value of an action parameter, or all key/value param pairs"""
604
cmd.append('--format=json')
605
action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
609
def action_set(values):
610
"""Sets the values to be returned after the action finishes"""
612
for k, v in list(values.items()):
613
cmd.append('{}={}'.format(k, v))
614
subprocess.check_call(cmd)
617
def action_fail(message):
618
"""Sets the action status to failed and sets the error message.
620
The results set by action_set are preserved."""
621
subprocess.check_call(['action-fail', message])
624
def status_set(workload_state, message):
625
"""Set the workload state with a message
627
Use status-set to set the workload state with a message which is visible
628
to the user via juju status. If the status-set command is not found then
629
assume this is juju < 1.23 and juju-log the message unstead.
631
workload_state -- valid juju workload state.
632
message -- status update message
634
valid_states = ['maintenance', 'blocked', 'waiting', 'active']
635
if workload_state not in valid_states:
637
'{!r} is not a valid workload state'.format(workload_state)
639
cmd = ['status-set', workload_state, message]
641
ret = subprocess.call(cmd)
645
if e.errno != errno.ENOENT:
647
log_message = 'status-set failed: {} {}'.format(workload_state,
649
log(log_message, level='INFO')
653
"""Retrieve the previously set juju workload state
655
If the status-set command is not found then assume this is juju < 1.23 and
660
raw_status = subprocess.check_output(cmd, universal_newlines=True)
661
status = raw_status.rstrip()
664
if e.errno == errno.ENOENT: