Merge ~xavpaice/charm-nagios:fix-lp1677580 into ~nagios-charmers/charm-nagios:master

Proposed by Xav Paice
Status: Merged
Approved by: James Hebden
Approved revision: dbc7c76615b10b763c5f5ca355a83ac8afee8b8b
Merged at revision: 38f049516d4865a1e3c1fec5289f6f189fff0631
Proposed branch: ~xavpaice/charm-nagios:fix-lp1677580
Merge into: ~nagios-charmers/charm-nagios:master
Diff against target: 6573 lines (+5335/-405)
39 files modified
Makefile (+14/-0)
bin/charm_helpers_sync.py (+252/-0)
charm-helpers.yaml (+2/-1)
config.yaml (+21/-0)
hooks/charmhelpers/__init__.py (+97/-0)
hooks/charmhelpers/contrib/__init__.py (+13/-0)
hooks/charmhelpers/contrib/ssl/__init__.py (+15/-1)
hooks/charmhelpers/contrib/ssl/service.py (+28/-18)
hooks/charmhelpers/core/__init__.py (+13/-0)
hooks/charmhelpers/core/decorators.py (+55/-0)
hooks/charmhelpers/core/files.py (+43/-0)
hooks/charmhelpers/core/fstab.py (+132/-0)
hooks/charmhelpers/core/hookenv.py (+742/-35)
hooks/charmhelpers/core/host.py (+755/-104)
hooks/charmhelpers/core/host_factory/__init__.py (+0/-0)
hooks/charmhelpers/core/host_factory/centos.py (+72/-0)
hooks/charmhelpers/core/host_factory/ubuntu.py (+89/-0)
hooks/charmhelpers/core/hugepage.py (+69/-0)
hooks/charmhelpers/core/kernel.py (+72/-0)
hooks/charmhelpers/core/kernel_factory/__init__.py (+0/-0)
hooks/charmhelpers/core/kernel_factory/centos.py (+17/-0)
hooks/charmhelpers/core/kernel_factory/ubuntu.py (+13/-0)
hooks/charmhelpers/core/services/__init__.py (+16/-0)
hooks/charmhelpers/core/services/base.py (+351/-0)
hooks/charmhelpers/core/services/helpers.py (+290/-0)
hooks/charmhelpers/core/strutils.py (+123/-0)
hooks/charmhelpers/core/sysctl.py (+54/-0)
hooks/charmhelpers/core/templating.py (+84/-0)
hooks/charmhelpers/core/unitdata.py (+518/-0)
hooks/charmhelpers/fetch/__init__.py (+135/-211)
hooks/charmhelpers/fetch/archiveurl.py (+126/-9)
hooks/charmhelpers/fetch/bzrurl.py (+52/-25)
hooks/charmhelpers/fetch/centos.py (+171/-0)
hooks/charmhelpers/fetch/giturl.py (+69/-0)
hooks/charmhelpers/fetch/snap.py (+134/-0)
hooks/charmhelpers/fetch/ubuntu.py (+583/-0)
hooks/charmhelpers/osplatform.py (+25/-0)
hooks/templates/localhost_nagios2.cfg.tmpl (+70/-0)
hooks/upgrade-charm (+20/-1)
Reviewer Review Type Date Requested Status
James Hebden (community) Approve
Review via email: mp+329236@code.launchpad.net
To post a comment you must log in.
Revision history for this message
James Hebden (ec0) wrote :

LGTM

Revision history for this message
James Hebden (ec0) wrote :

Yep, LGTM.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index c75b2e9..9d48829 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -1,3 +1,7 @@
6+#!/usr/bin/make
7+PYTHON := /usr/bin/python3
8+export PYTHONPATH := hooks
9+
10 default:
11 echo Nothing to do
12
13@@ -12,3 +16,13 @@ test:
14 tests/22-extraconfig-test
15 tests/23-livestatus-test
16 tests/24-pagerduty-test
17+
18+bin/charm_helpers_sync.py:
19+ @mkdir -p bin
20+ @bzr cat lp:charm-helpers/tools/charm_helpers_sync/charm_helpers_sync.py \
21+ > bin/charm_helpers_sync.py
22+
23+sync: bin/charm_helpers_sync.py
24+ @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers.yaml
25+
26+
27diff --git a/bin/charm_helpers_sync.py b/bin/charm_helpers_sync.py
28new file mode 100644
29index 0000000..bd79460
30--- /dev/null
31+++ b/bin/charm_helpers_sync.py
32@@ -0,0 +1,252 @@
33+#!/usr/bin/python
34+
35+# Copyright 2014-2015 Canonical Limited.
36+#
37+# Licensed under the Apache License, Version 2.0 (the "License");
38+# you may not use this file except in compliance with the License.
39+# You may obtain a copy of the License at
40+#
41+# http://www.apache.org/licenses/LICENSE-2.0
42+#
43+# Unless required by applicable law or agreed to in writing, software
44+# distributed under the License is distributed on an "AS IS" BASIS,
45+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
46+# See the License for the specific language governing permissions and
47+# limitations under the License.
48+
49+# Authors:
50+# Adam Gandelman <adamg@ubuntu.com>
51+
52+import logging
53+import optparse
54+import os
55+import subprocess
56+import shutil
57+import sys
58+import tempfile
59+import yaml
60+from fnmatch import fnmatch
61+
62+import six
63+
64+CHARM_HELPERS_BRANCH = 'lp:charm-helpers'
65+
66+
67+def parse_config(conf_file):
68+ if not os.path.isfile(conf_file):
69+ logging.error('Invalid config file: %s.' % conf_file)
70+ return False
71+ return yaml.load(open(conf_file).read())
72+
73+
74+def clone_helpers(work_dir, branch):
75+ dest = os.path.join(work_dir, 'charm-helpers')
76+ logging.info('Checking out %s to %s.' % (branch, dest))
77+ cmd = ['bzr', 'checkout', '--lightweight', branch, dest]
78+ subprocess.check_call(cmd)
79+ return dest
80+
81+
82+def _module_path(module):
83+ return os.path.join(*module.split('.'))
84+
85+
86+def _src_path(src, module):
87+ return os.path.join(src, 'charmhelpers', _module_path(module))
88+
89+
90+def _dest_path(dest, module):
91+ return os.path.join(dest, _module_path(module))
92+
93+
94+def _is_pyfile(path):
95+ return os.path.isfile(path + '.py')
96+
97+
98+def ensure_init(path):
99+ '''
100+ ensure directories leading up to path are importable, omitting
101+ parent directory, eg path='/hooks/helpers/foo'/:
102+ hooks/
103+ hooks/helpers/__init__.py
104+ hooks/helpers/foo/__init__.py
105+ '''
106+ for d, dirs, files in os.walk(os.path.join(*path.split('/')[:2])):
107+ _i = os.path.join(d, '__init__.py')
108+ if not os.path.exists(_i):
109+ logging.info('Adding missing __init__.py: %s' % _i)
110+ open(_i, 'wb').close()
111+
112+
113+def sync_pyfile(src, dest):
114+ src = src + '.py'
115+ src_dir = os.path.dirname(src)
116+ logging.info('Syncing pyfile: %s -> %s.' % (src, dest))
117+ if not os.path.exists(dest):
118+ os.makedirs(dest)
119+ shutil.copy(src, dest)
120+ if os.path.isfile(os.path.join(src_dir, '__init__.py')):
121+ shutil.copy(os.path.join(src_dir, '__init__.py'),
122+ dest)
123+ ensure_init(dest)
124+
125+
126+def get_filter(opts=None):
127+ opts = opts or []
128+ if 'inc=*' in opts:
129+ # do not filter any files, include everything
130+ return None
131+
132+ def _filter(dir, ls):
133+ incs = [opt.split('=').pop() for opt in opts if 'inc=' in opt]
134+ _filter = []
135+ for f in ls:
136+ _f = os.path.join(dir, f)
137+
138+ if not os.path.isdir(_f) and not _f.endswith('.py') and incs:
139+ if True not in [fnmatch(_f, inc) for inc in incs]:
140+ logging.debug('Not syncing %s, does not match include '
141+ 'filters (%s)' % (_f, incs))
142+ _filter.append(f)
143+ else:
144+ logging.debug('Including file, which matches include '
145+ 'filters (%s): %s' % (incs, _f))
146+ elif (os.path.isfile(_f) and not _f.endswith('.py')):
147+ logging.debug('Not syncing file: %s' % f)
148+ _filter.append(f)
149+ elif (os.path.isdir(_f) and not
150+ os.path.isfile(os.path.join(_f, '__init__.py'))):
151+ logging.debug('Not syncing directory: %s' % f)
152+ _filter.append(f)
153+ return _filter
154+ return _filter
155+
156+
157+def sync_directory(src, dest, opts=None):
158+ if os.path.exists(dest):
159+ logging.debug('Removing existing directory: %s' % dest)
160+ shutil.rmtree(dest)
161+ logging.info('Syncing directory: %s -> %s.' % (src, dest))
162+
163+ shutil.copytree(src, dest, ignore=get_filter(opts))
164+ ensure_init(dest)
165+
166+
167+def sync(src, dest, module, opts=None):
168+
169+ # Sync charmhelpers/__init__.py for bootstrap code.
170+ sync_pyfile(_src_path(src, '__init__'), dest)
171+
172+ # Sync other __init__.py files in the path leading to module.
173+ m = []
174+ steps = module.split('.')[:-1]
175+ while steps:
176+ m.append(steps.pop(0))
177+ init = '.'.join(m + ['__init__'])
178+ sync_pyfile(_src_path(src, init),
179+ os.path.dirname(_dest_path(dest, init)))
180+
181+ # Sync the module, or maybe a .py file.
182+ if os.path.isdir(_src_path(src, module)):
183+ sync_directory(_src_path(src, module), _dest_path(dest, module), opts)
184+ elif _is_pyfile(_src_path(src, module)):
185+ sync_pyfile(_src_path(src, module),
186+ os.path.dirname(_dest_path(dest, module)))
187+ else:
188+ logging.warn('Could not sync: %s. Neither a pyfile or directory, '
189+ 'does it even exist?' % module)
190+
191+
192+def parse_sync_options(options):
193+ if not options:
194+ return []
195+ return options.split(',')
196+
197+
198+def extract_options(inc, global_options=None):
199+ global_options = global_options or []
200+ if global_options and isinstance(global_options, six.string_types):
201+ global_options = [global_options]
202+ if '|' not in inc:
203+ return (inc, global_options)
204+ inc, opts = inc.split('|')
205+ return (inc, parse_sync_options(opts) + global_options)
206+
207+
208+def sync_helpers(include, src, dest, options=None):
209+ if not os.path.isdir(dest):
210+ os.makedirs(dest)
211+
212+ global_options = parse_sync_options(options)
213+
214+ for inc in include:
215+ if isinstance(inc, str):
216+ inc, opts = extract_options(inc, global_options)
217+ sync(src, dest, inc, opts)
218+ elif isinstance(inc, dict):
219+ # could also do nested dicts here.
220+ for k, v in six.iteritems(inc):
221+ if isinstance(v, list):
222+ for m in v:
223+ inc, opts = extract_options(m, global_options)
224+ sync(src, dest, '%s.%s' % (k, inc), opts)
225+
226+
227+if __name__ == '__main__':
228+ parser = optparse.OptionParser()
229+ parser.add_option('-c', '--config', action='store', dest='config',
230+ default=None, help='helper config file')
231+ parser.add_option('-D', '--debug', action='store_true', dest='debug',
232+ default=False, help='debug')
233+ parser.add_option('-b', '--branch', action='store', dest='branch',
234+ help='charm-helpers bzr branch (overrides config)')
235+ parser.add_option('-d', '--destination', action='store', dest='dest_dir',
236+ help='sync destination dir (overrides config)')
237+ (opts, args) = parser.parse_args()
238+
239+ if opts.debug:
240+ logging.basicConfig(level=logging.DEBUG)
241+ else:
242+ logging.basicConfig(level=logging.INFO)
243+
244+ if opts.config:
245+ logging.info('Loading charm helper config from %s.' % opts.config)
246+ config = parse_config(opts.config)
247+ if not config:
248+ logging.error('Could not parse config from %s.' % opts.config)
249+ sys.exit(1)
250+ else:
251+ config = {}
252+
253+ if 'branch' not in config:
254+ config['branch'] = CHARM_HELPERS_BRANCH
255+ if opts.branch:
256+ config['branch'] = opts.branch
257+ if opts.dest_dir:
258+ config['destination'] = opts.dest_dir
259+
260+ if 'destination' not in config:
261+ logging.error('No destination dir. specified as option or config.')
262+ sys.exit(1)
263+
264+ if 'include' not in config:
265+ if not args:
266+ logging.error('No modules to sync specified as option or config.')
267+ sys.exit(1)
268+ config['include'] = []
269+ [config['include'].append(a) for a in args]
270+
271+ sync_options = None
272+ if 'options' in config:
273+ sync_options = config['options']
274+ tmpd = tempfile.mkdtemp()
275+ try:
276+ checkout = clone_helpers(tmpd, config['branch'])
277+ sync_helpers(config['include'], checkout, config['destination'],
278+ options=sync_options)
279+ except Exception as e:
280+ logging.error("Could not sync: %s" % e)
281+ raise e
282+ finally:
283+ logging.debug('Cleaning up %s' % tmpd)
284+ shutil.rmtree(tmpd)
285diff --git a/charm-helpers.yaml b/charm-helpers.yaml
286index 4c97181..e5f7760 100644
287--- a/charm-helpers.yaml
288+++ b/charm-helpers.yaml
289@@ -1,6 +1,7 @@
290 destination: hooks/charmhelpers
291-branch: lp:~openstack-charmers/charm-helpers/ssl-everywhere
292+branch: lp:charm-helpers
293 include:
294 - core
295 - fetch
296+ - osplatform
297 - contrib.ssl
298diff --git a/config.yaml b/config.yaml
299index 3eb834a..77dc2a1 100644
300--- a/config.yaml
301+++ b/config.yaml
302@@ -144,3 +144,24 @@ options:
303 Password to use for Nagios administrative access. If not
304 provided, a password will be generated (see documentation for
305 instructions on retrieving the generated password.)
306+ monitor_self:
307+ type: boolean
308+ default: true
309+ description: |
310+ If true, enable monitoring of the nagios unit itself.
311+ nagios_host_context:
312+ default: "juju"
313+ type: string
314+ description: |
315+ a string that will be prepended to instance name to set the host name
316+ in nagios. So for instance the hostname would be something like:
317+ juju-postgresql-0
318+ If you're running multiple environments with the same services in them
319+ this allows you to differentiate between them.
320+ load_monitor:
321+ default: '5.0!4.0!3.0!10.0!6.0!4.0'
322+ type: string
323+ description: |
324+ A string to pass to the Nagios load monitoring command. Default is
325+ to report warning at 5.0, 4.0 and 3.0 averages, critical at 10.0,
326+ 6.0 and 4.0.
327diff --git a/hooks/charmhelpers/__init__.py b/hooks/charmhelpers/__init__.py
328index e69de29..e7aa471 100644
329--- a/hooks/charmhelpers/__init__.py
330+++ b/hooks/charmhelpers/__init__.py
331@@ -0,0 +1,97 @@
332+# Copyright 2014-2015 Canonical Limited.
333+#
334+# Licensed under the Apache License, Version 2.0 (the "License");
335+# you may not use this file except in compliance with the License.
336+# You may obtain a copy of the License at
337+#
338+# http://www.apache.org/licenses/LICENSE-2.0
339+#
340+# Unless required by applicable law or agreed to in writing, software
341+# distributed under the License is distributed on an "AS IS" BASIS,
342+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
343+# See the License for the specific language governing permissions and
344+# limitations under the License.
345+
346+# Bootstrap charm-helpers, installing its dependencies if necessary using
347+# only standard libraries.
348+from __future__ import print_function
349+from __future__ import absolute_import
350+
351+import functools
352+import inspect
353+import subprocess
354+import sys
355+
356+try:
357+ import six # flake8: noqa
358+except ImportError:
359+ if sys.version_info.major == 2:
360+ subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
361+ else:
362+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
363+ import six # flake8: noqa
364+
365+try:
366+ import yaml # flake8: noqa
367+except ImportError:
368+ if sys.version_info.major == 2:
369+ subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
370+ else:
371+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
372+ import yaml # flake8: noqa
373+
374+
375+# Holds a list of mapping of mangled function names that have been deprecated
376+# using the @deprecate decorator below. This is so that the warning is only
377+# printed once for each usage of the function.
378+__deprecated_functions = {}
379+
380+
381+def deprecate(warning, date=None, log=None):
382+ """Add a deprecation warning the first time the function is used.
383+ The date, which is a string in semi-ISO8660 format indicate the year-month
384+ that the function is officially going to be removed.
385+
386+ usage:
387+
388+ @deprecate('use core/fetch/add_source() instead', '2017-04')
389+ def contributed_add_source_thing(...):
390+ ...
391+
392+ And it then prints to the log ONCE that the function is deprecated.
393+ The reason for passing the logging function (log) is so that hookenv.log
394+ can be used for a charm if needed.
395+
396+ :param warning: String to indicat where it has moved ot.
397+ :param date: optional sting, in YYYY-MM format to indicate when the
398+ function will definitely (probably) be removed.
399+ :param log: The log function to call to log. If not, logs to stdout
400+ """
401+ def wrap(f):
402+
403+ @functools.wraps(f)
404+ def wrapped_f(*args, **kwargs):
405+ try:
406+ module = inspect.getmodule(f)
407+ file = inspect.getsourcefile(f)
408+ lines = inspect.getsourcelines(f)
409+ f_name = "{}-{}-{}..{}-{}".format(
410+ module.__name__, file, lines[0], lines[-1], f.__name__)
411+ except (IOError, TypeError):
412+ # assume it was local, so just use the name of the function
413+ f_name = f.__name__
414+ if f_name not in __deprecated_functions:
415+ __deprecated_functions[f_name] = True
416+ s = "DEPRECATION WARNING: Function {} is being removed".format(
417+ f.__name__)
418+ if date:
419+ s = "{} on/around {}".format(s, date)
420+ if warning:
421+ s = "{} : {}".format(s, warning)
422+ if log:
423+ log(s)
424+ else:
425+ print(s)
426+ return f(*args, **kwargs)
427+ return wrapped_f
428+ return wrap
429diff --git a/hooks/charmhelpers/contrib/__init__.py b/hooks/charmhelpers/contrib/__init__.py
430index e69de29..d7567b8 100644
431--- a/hooks/charmhelpers/contrib/__init__.py
432+++ b/hooks/charmhelpers/contrib/__init__.py
433@@ -0,0 +1,13 @@
434+# Copyright 2014-2015 Canonical Limited.
435+#
436+# Licensed under the Apache License, Version 2.0 (the "License");
437+# you may not use this file except in compliance with the License.
438+# You may obtain a copy of the License at
439+#
440+# http://www.apache.org/licenses/LICENSE-2.0
441+#
442+# Unless required by applicable law or agreed to in writing, software
443+# distributed under the License is distributed on an "AS IS" BASIS,
444+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
445+# See the License for the specific language governing permissions and
446+# limitations under the License.
447diff --git a/hooks/charmhelpers/contrib/ssl/__init__.py b/hooks/charmhelpers/contrib/ssl/__init__.py
448index 2999c0a..1d238b5 100644
449--- a/hooks/charmhelpers/contrib/ssl/__init__.py
450+++ b/hooks/charmhelpers/contrib/ssl/__init__.py
451@@ -1,3 +1,17 @@
452+# Copyright 2014-2015 Canonical Limited.
453+#
454+# Licensed under the Apache License, Version 2.0 (the "License");
455+# you may not use this file except in compliance with the License.
456+# You may obtain a copy of the License at
457+#
458+# http://www.apache.org/licenses/LICENSE-2.0
459+#
460+# Unless required by applicable law or agreed to in writing, software
461+# distributed under the License is distributed on an "AS IS" BASIS,
462+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
463+# See the License for the specific language governing permissions and
464+# limitations under the License.
465+
466 import subprocess
467 from charmhelpers.core import hookenv
468
469@@ -74,5 +88,5 @@ def generate_selfsigned(keyfile, certfile, keysize="1024", config=None, subject=
470 subprocess.check_call(cmd)
471 return True
472 except Exception as e:
473- print "Execution of openssl command failed:\n{}".format(e)
474+ print("Execution of openssl command failed:\n{}".format(e))
475 return False
476diff --git a/hooks/charmhelpers/contrib/ssl/service.py b/hooks/charmhelpers/contrib/ssl/service.py
477index 295f721..06b534f 100644
478--- a/hooks/charmhelpers/contrib/ssl/service.py
479+++ b/hooks/charmhelpers/contrib/ssl/service.py
480@@ -1,13 +1,23 @@
481-import logging
482+# Copyright 2014-2015 Canonical Limited.
483+#
484+# Licensed under the Apache License, Version 2.0 (the "License");
485+# you may not use this file except in compliance with the License.
486+# You may obtain a copy of the License at
487+#
488+# http://www.apache.org/licenses/LICENSE-2.0
489+#
490+# Unless required by applicable law or agreed to in writing, software
491+# distributed under the License is distributed on an "AS IS" BASIS,
492+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
493+# See the License for the specific language governing permissions and
494+# limitations under the License.
495+
496 import os
497 from os.path import join as path_join
498 from os.path import exists
499 import subprocess
500
501-
502-log = logging.getLogger("service_ca")
503-
504-logging.basicConfig(level=logging.DEBUG)
505+from charmhelpers.core.hookenv import log, DEBUG
506
507 STD_CERT = "standard"
508
509@@ -46,7 +56,7 @@ class ServiceCA(object):
510 ###############
511
512 def init(self):
513- log.debug("initializing service ca")
514+ log("initializing service ca", level=DEBUG)
515 if not exists(self.ca_dir):
516 self._init_ca_dir(self.ca_dir)
517 self._init_ca()
518@@ -75,23 +85,23 @@ class ServiceCA(object):
519 os.mkdir(sd)
520
521 if not exists(path_join(ca_dir, 'serial')):
522- with open(path_join(ca_dir, 'serial'), 'wb') as fh:
523+ with open(path_join(ca_dir, 'serial'), 'w') as fh:
524 fh.write('02\n')
525
526 if not exists(path_join(ca_dir, 'index.txt')):
527- with open(path_join(ca_dir, 'index.txt'), 'wb') as fh:
528+ with open(path_join(ca_dir, 'index.txt'), 'w') as fh:
529 fh.write('')
530
531 def _init_ca(self):
532 """Generate the root ca's cert and key.
533 """
534 if not exists(path_join(self.ca_dir, 'ca.cnf')):
535- with open(path_join(self.ca_dir, 'ca.cnf'), 'wb') as fh:
536+ with open(path_join(self.ca_dir, 'ca.cnf'), 'w') as fh:
537 fh.write(
538 CA_CONF_TEMPLATE % (self.get_conf_variables()))
539
540 if not exists(path_join(self.ca_dir, 'signing.cnf')):
541- with open(path_join(self.ca_dir, 'signing.cnf'), 'wb') as fh:
542+ with open(path_join(self.ca_dir, 'signing.cnf'), 'w') as fh:
543 fh.write(
544 SIGNING_CONF_TEMPLATE % (self.get_conf_variables()))
545
546@@ -103,7 +113,7 @@ class ServiceCA(object):
547 '-keyout', self.ca_key, '-out', self.ca_cert,
548 '-outform', 'PEM']
549 output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
550- log.debug("CA Init:\n %s", output)
551+ log("CA Init:\n %s" % output, level=DEBUG)
552
553 def get_conf_variables(self):
554 return dict(
555@@ -127,7 +137,7 @@ class ServiceCA(object):
556 return self.get_certificate(common_name)
557
558 def get_certificate(self, common_name):
559- if not common_name in self:
560+ if common_name not in self:
561 raise ValueError("No certificate for %s" % common_name)
562 key_p = path_join(self.ca_dir, "certs", "%s.key" % common_name)
563 crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
564@@ -147,15 +157,15 @@ class ServiceCA(object):
565 subj = '/O=%(org_name)s/OU=%(org_unit_name)s/CN=%(common_name)s' % (
566 template_vars)
567
568- log.debug("CA Create Cert %s", common_name)
569+ log("CA Create Cert %s" % common_name, level=DEBUG)
570 cmd = ['openssl', 'req', '-sha1', '-newkey', 'rsa:2048',
571 '-nodes', '-days', self.default_expiry,
572 '-keyout', key_p, '-out', csr_p, '-subj', subj]
573- subprocess.check_call(cmd)
574+ subprocess.check_call(cmd, stderr=subprocess.PIPE)
575 cmd = ['openssl', 'rsa', '-in', key_p, '-out', key_p]
576- subprocess.check_call(cmd)
577+ subprocess.check_call(cmd, stderr=subprocess.PIPE)
578
579- log.debug("CA Sign Cert %s", common_name)
580+ log("CA Sign Cert %s" % common_name, level=DEBUG)
581 if self.cert_type == MYSQL_CERT:
582 cmd = ['openssl', 'x509', '-req',
583 '-in', csr_p, '-days', self.default_expiry,
584@@ -166,8 +176,8 @@ class ServiceCA(object):
585 '-extensions', 'req_extensions',
586 '-days', self.default_expiry, '-notext',
587 '-in', csr_p, '-out', crt_p, '-subj', subj, '-batch']
588- log.debug("running %s", " ".join(cmd))
589- subprocess.check_call(cmd)
590+ log("running %s" % " ".join(cmd), level=DEBUG)
591+ subprocess.check_call(cmd, stderr=subprocess.PIPE)
592
593 def get_ca_bundle(self):
594 with open(self.ca_cert) as fh:
595diff --git a/hooks/charmhelpers/core/__init__.py b/hooks/charmhelpers/core/__init__.py
596index e69de29..d7567b8 100644
597--- a/hooks/charmhelpers/core/__init__.py
598+++ b/hooks/charmhelpers/core/__init__.py
599@@ -0,0 +1,13 @@
600+# Copyright 2014-2015 Canonical Limited.
601+#
602+# Licensed under the Apache License, Version 2.0 (the "License");
603+# you may not use this file except in compliance with the License.
604+# You may obtain a copy of the License at
605+#
606+# http://www.apache.org/licenses/LICENSE-2.0
607+#
608+# Unless required by applicable law or agreed to in writing, software
609+# distributed under the License is distributed on an "AS IS" BASIS,
610+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
611+# See the License for the specific language governing permissions and
612+# limitations under the License.
613diff --git a/hooks/charmhelpers/core/decorators.py b/hooks/charmhelpers/core/decorators.py
614new file mode 100644
615index 0000000..6ad41ee
616--- /dev/null
617+++ b/hooks/charmhelpers/core/decorators.py
618@@ -0,0 +1,55 @@
619+# Copyright 2014-2015 Canonical Limited.
620+#
621+# Licensed under the Apache License, Version 2.0 (the "License");
622+# you may not use this file except in compliance with the License.
623+# You may obtain a copy of the License at
624+#
625+# http://www.apache.org/licenses/LICENSE-2.0
626+#
627+# Unless required by applicable law or agreed to in writing, software
628+# distributed under the License is distributed on an "AS IS" BASIS,
629+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
630+# See the License for the specific language governing permissions and
631+# limitations under the License.
632+
633+#
634+# Copyright 2014 Canonical Ltd.
635+#
636+# Authors:
637+# Edward Hope-Morley <opentastic@gmail.com>
638+#
639+
640+import time
641+
642+from charmhelpers.core.hookenv import (
643+ log,
644+ INFO,
645+)
646+
647+
648+def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
649+ """If the decorated function raises exception exc_type, allow num_retries
650+ retry attempts before raise the exception.
651+ """
652+ def _retry_on_exception_inner_1(f):
653+ def _retry_on_exception_inner_2(*args, **kwargs):
654+ retries = num_retries
655+ multiplier = 1
656+ while True:
657+ try:
658+ return f(*args, **kwargs)
659+ except exc_type:
660+ if not retries:
661+ raise
662+
663+ delay = base_delay * multiplier
664+ multiplier += 1
665+ log("Retrying '%s' %d more times (delay=%s)" %
666+ (f.__name__, retries, delay), level=INFO)
667+ retries -= 1
668+ if delay:
669+ time.sleep(delay)
670+
671+ return _retry_on_exception_inner_2
672+
673+ return _retry_on_exception_inner_1
674diff --git a/hooks/charmhelpers/core/files.py b/hooks/charmhelpers/core/files.py
675new file mode 100644
676index 0000000..fdd82b7
677--- /dev/null
678+++ b/hooks/charmhelpers/core/files.py
679@@ -0,0 +1,43 @@
680+#!/usr/bin/env python
681+# -*- coding: utf-8 -*-
682+
683+# Copyright 2014-2015 Canonical Limited.
684+#
685+# Licensed under the Apache License, Version 2.0 (the "License");
686+# you may not use this file except in compliance with the License.
687+# You may obtain a copy of the License at
688+#
689+# http://www.apache.org/licenses/LICENSE-2.0
690+#
691+# Unless required by applicable law or agreed to in writing, software
692+# distributed under the License is distributed on an "AS IS" BASIS,
693+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
694+# See the License for the specific language governing permissions and
695+# limitations under the License.
696+
697+__author__ = 'Jorge Niedbalski <niedbalski@ubuntu.com>'
698+
699+import os
700+import subprocess
701+
702+
703+def sed(filename, before, after, flags='g'):
704+ """
705+ Search and replaces the given pattern on filename.
706+
707+ :param filename: relative or absolute file path.
708+ :param before: expression to be replaced (see 'man sed')
709+ :param after: expression to replace with (see 'man sed')
710+ :param flags: sed-compatible regex flags in example, to make
711+ the search and replace case insensitive, specify ``flags="i"``.
712+ The ``g`` flag is always specified regardless, so you do not
713+ need to remember to include it when overriding this parameter.
714+ :returns: If the sed command exit code was zero then return,
715+ otherwise raise CalledProcessError.
716+ """
717+ expression = r's/{0}/{1}/{2}'.format(before,
718+ after, flags)
719+
720+ return subprocess.check_call(["sed", "-i", "-r", "-e",
721+ expression,
722+ os.path.expanduser(filename)])
723diff --git a/hooks/charmhelpers/core/fstab.py b/hooks/charmhelpers/core/fstab.py
724new file mode 100644
725index 0000000..d9fa915
726--- /dev/null
727+++ b/hooks/charmhelpers/core/fstab.py
728@@ -0,0 +1,132 @@
729+#!/usr/bin/env python
730+# -*- coding: utf-8 -*-
731+
732+# Copyright 2014-2015 Canonical Limited.
733+#
734+# Licensed under the Apache License, Version 2.0 (the "License");
735+# you may not use this file except in compliance with the License.
736+# You may obtain a copy of the License at
737+#
738+# http://www.apache.org/licenses/LICENSE-2.0
739+#
740+# Unless required by applicable law or agreed to in writing, software
741+# distributed under the License is distributed on an "AS IS" BASIS,
742+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
743+# See the License for the specific language governing permissions and
744+# limitations under the License.
745+
746+import io
747+import os
748+
749+__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
750+
751+
752+class Fstab(io.FileIO):
753+ """This class extends file in order to implement a file reader/writer
754+ for file `/etc/fstab`
755+ """
756+
757+ class Entry(object):
758+ """Entry class represents a non-comment line on the `/etc/fstab` file
759+ """
760+ def __init__(self, device, mountpoint, filesystem,
761+ options, d=0, p=0):
762+ self.device = device
763+ self.mountpoint = mountpoint
764+ self.filesystem = filesystem
765+
766+ if not options:
767+ options = "defaults"
768+
769+ self.options = options
770+ self.d = int(d)
771+ self.p = int(p)
772+
773+ def __eq__(self, o):
774+ return str(self) == str(o)
775+
776+ def __str__(self):
777+ return "{} {} {} {} {} {}".format(self.device,
778+ self.mountpoint,
779+ self.filesystem,
780+ self.options,
781+ self.d,
782+ self.p)
783+
784+ DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
785+
786+ def __init__(self, path=None):
787+ if path:
788+ self._path = path
789+ else:
790+ self._path = self.DEFAULT_PATH
791+ super(Fstab, self).__init__(self._path, 'rb+')
792+
793+ def _hydrate_entry(self, line):
794+ # NOTE: use split with no arguments to split on any
795+ # whitespace including tabs
796+ return Fstab.Entry(*filter(
797+ lambda x: x not in ('', None),
798+ line.strip("\n").split()))
799+
800+ @property
801+ def entries(self):
802+ self.seek(0)
803+ for line in self.readlines():
804+ line = line.decode('us-ascii')
805+ try:
806+ if line.strip() and not line.strip().startswith("#"):
807+ yield self._hydrate_entry(line)
808+ except ValueError:
809+ pass
810+
811+ def get_entry_by_attr(self, attr, value):
812+ for entry in self.entries:
813+ e_attr = getattr(entry, attr)
814+ if e_attr == value:
815+ return entry
816+ return None
817+
818+ def add_entry(self, entry):
819+ if self.get_entry_by_attr('device', entry.device):
820+ return False
821+
822+ self.write((str(entry) + '\n').encode('us-ascii'))
823+ self.truncate()
824+ return entry
825+
826+ def remove_entry(self, entry):
827+ self.seek(0)
828+
829+ lines = [l.decode('us-ascii') for l in self.readlines()]
830+
831+ found = False
832+ for index, line in enumerate(lines):
833+ if line.strip() and not line.strip().startswith("#"):
834+ if self._hydrate_entry(line) == entry:
835+ found = True
836+ break
837+
838+ if not found:
839+ return False
840+
841+ lines.remove(line)
842+
843+ self.seek(0)
844+ self.write(''.join(lines).encode('us-ascii'))
845+ self.truncate()
846+ return True
847+
848+ @classmethod
849+ def remove_by_mountpoint(cls, mountpoint, path=None):
850+ fstab = cls(path=path)
851+ entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
852+ if entry:
853+ return fstab.remove_entry(entry)
854+ return False
855+
856+ @classmethod
857+ def add(cls, device, mountpoint, filesystem, options=None, path=None):
858+ return cls(path=path).add_entry(Fstab.Entry(device,
859+ mountpoint, filesystem,
860+ options=options))
861diff --git a/hooks/charmhelpers/core/hookenv.py b/hooks/charmhelpers/core/hookenv.py
862index 505c202..12f37b2 100644
863--- a/hooks/charmhelpers/core/hookenv.py
864+++ b/hooks/charmhelpers/core/hookenv.py
865@@ -1,22 +1,49 @@
866+# Copyright 2014-2015 Canonical Limited.
867+#
868+# Licensed under the Apache License, Version 2.0 (the "License");
869+# you may not use this file except in compliance with the License.
870+# You may obtain a copy of the License at
871+#
872+# http://www.apache.org/licenses/LICENSE-2.0
873+#
874+# Unless required by applicable law or agreed to in writing, software
875+# distributed under the License is distributed on an "AS IS" BASIS,
876+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
877+# See the License for the specific language governing permissions and
878+# limitations under the License.
879+
880 "Interactions with the Juju environment"
881 # Copyright 2013 Canonical Ltd.
882 #
883 # Authors:
884 # Charm Helpers Developers <juju@lists.ubuntu.com>
885
886+from __future__ import print_function
887+import copy
888+from distutils.version import LooseVersion
889+from functools import wraps
890+import glob
891 import os
892 import json
893 import yaml
894 import subprocess
895 import sys
896-import UserDict
897+import errno
898+import tempfile
899 from subprocess import CalledProcessError
900
901+import six
902+if not six.PY3:
903+ from UserDict import UserDict
904+else:
905+ from collections import UserDict
906+
907 CRITICAL = "CRITICAL"
908 ERROR = "ERROR"
909 WARNING = "WARNING"
910 INFO = "INFO"
911 DEBUG = "DEBUG"
912+TRACE = "TRACE"
913 MARKER = object()
914
915 cache = {}
916@@ -25,7 +52,7 @@ cache = {}
917 def cached(func):
918 """Cache return values for multiple executions of func + args
919
920- For example:
921+ For example::
922
923 @cached
924 def unit_get(attribute):
925@@ -35,15 +62,18 @@ def cached(func):
926
927 will cache the result of unit_get + 'test' for future calls.
928 """
929+ @wraps(func)
930 def wrapper(*args, **kwargs):
931 global cache
932 key = str((func, args, kwargs))
933 try:
934 return cache[key]
935 except KeyError:
936- res = func(*args, **kwargs)
937- cache[key] = res
938- return res
939+ pass # Drop out of the exception handler scope.
940+ res = func(*args, **kwargs)
941+ cache[key] = res
942+ return res
943+ wrapper._wrapped = func
944 return wrapper
945
946
947@@ -63,16 +93,29 @@ def log(message, level=None):
948 command = ['juju-log']
949 if level:
950 command += ['-l', level]
951+ if not isinstance(message, six.string_types):
952+ message = repr(message)
953 command += [message]
954- subprocess.call(command)
955+ # Missing juju-log should not cause failures in unit tests
956+ # Send log output to stderr
957+ try:
958+ subprocess.call(command)
959+ except OSError as e:
960+ if e.errno == errno.ENOENT:
961+ if level:
962+ message = "{}: {}".format(level, message)
963+ message = "juju-log: {}".format(message)
964+ print(message, file=sys.stderr)
965+ else:
966+ raise
967
968
969-class Serializable(UserDict.IterableUserDict):
970+class Serializable(UserDict):
971 """Wrapper, an object that can be serialized to yaml or json"""
972
973 def __init__(self, obj):
974 # wrap the object
975- UserDict.IterableUserDict.__init__(self)
976+ UserDict.__init__(self)
977 self.data = obj
978
979 def __getattr__(self, attr):
980@@ -130,9 +173,19 @@ def relation_type():
981 return os.environ.get('JUJU_RELATION', None)
982
983
984-def relation_id():
985- """The relation ID for the current relation hook"""
986- return os.environ.get('JUJU_RELATION_ID', None)
987+@cached
988+def relation_id(relation_name=None, service_or_unit=None):
989+ """The relation ID for the current or a specified relation"""
990+ if not relation_name and not service_or_unit:
991+ return os.environ.get('JUJU_RELATION_ID', None)
992+ elif relation_name and service_or_unit:
993+ service_name = service_or_unit.split('/')[0]
994+ for relid in relation_ids(relation_name):
995+ remote_service = remote_service_name(relid)
996+ if remote_service == service_name:
997+ return relid
998+ else:
999+ raise ValueError('Must specify neither or both of relation_name and service_or_unit')
1000
1001
1002 def local_unit():
1003@@ -142,7 +195,7 @@ def local_unit():
1004
1005 def remote_unit():
1006 """The remote unit for the current relation hook"""
1007- return os.environ['JUJU_REMOTE_UNIT']
1008+ return os.environ.get('JUJU_REMOTE_UNIT', None)
1009
1010
1011 def service_name():
1012@@ -150,9 +203,149 @@ def service_name():
1013 return local_unit().split('/')[0]
1014
1015
1016+def principal_unit():
1017+ """Returns the principal unit of this unit, otherwise None"""
1018+ # Juju 2.2 and above provides JUJU_PRINCIPAL_UNIT
1019+ principal_unit = os.environ.get('JUJU_PRINCIPAL_UNIT', None)
1020+ # If it's empty, then this unit is the principal
1021+ if principal_unit == '':
1022+ return os.environ['JUJU_UNIT_NAME']
1023+ elif principal_unit is not None:
1024+ return principal_unit
1025+ # For Juju 2.1 and below, let's try work out the principle unit by
1026+ # the various charms' metadata.yaml.
1027+ for reltype in relation_types():
1028+ for rid in relation_ids(reltype):
1029+ for unit in related_units(rid):
1030+ md = _metadata_unit(unit)
1031+ subordinate = md.pop('subordinate', None)
1032+ if not subordinate:
1033+ return unit
1034+ return None
1035+
1036+
1037+@cached
1038+def remote_service_name(relid=None):
1039+ """The remote service name for a given relation-id (or the current relation)"""
1040+ if relid is None:
1041+ unit = remote_unit()
1042+ else:
1043+ units = related_units(relid)
1044+ unit = units[0] if units else None
1045+ return unit.split('/')[0] if unit else None
1046+
1047+
1048 def hook_name():
1049 """The name of the currently executing hook"""
1050- return os.path.basename(sys.argv[0])
1051+ return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
1052+
1053+
1054+class Config(dict):
1055+ """A dictionary representation of the charm's config.yaml, with some
1056+ extra features:
1057+
1058+ - See which values in the dictionary have changed since the previous hook.
1059+ - For values that have changed, see what the previous value was.
1060+ - Store arbitrary data for use in a later hook.
1061+
1062+ NOTE: Do not instantiate this object directly - instead call
1063+ ``hookenv.config()``, which will return an instance of :class:`Config`.
1064+
1065+ Example usage::
1066+
1067+ >>> # inside a hook
1068+ >>> from charmhelpers.core import hookenv
1069+ >>> config = hookenv.config()
1070+ >>> config['foo']
1071+ 'bar'
1072+ >>> # store a new key/value for later use
1073+ >>> config['mykey'] = 'myval'
1074+
1075+
1076+ >>> # user runs `juju set mycharm foo=baz`
1077+ >>> # now we're inside subsequent config-changed hook
1078+ >>> config = hookenv.config()
1079+ >>> config['foo']
1080+ 'baz'
1081+ >>> # test to see if this val has changed since last hook
1082+ >>> config.changed('foo')
1083+ True
1084+ >>> # what was the previous value?
1085+ >>> config.previous('foo')
1086+ 'bar'
1087+ >>> # keys/values that we add are preserved across hooks
1088+ >>> config['mykey']
1089+ 'myval'
1090+
1091+ """
1092+ CONFIG_FILE_NAME = '.juju-persistent-config'
1093+
1094+ def __init__(self, *args, **kw):
1095+ super(Config, self).__init__(*args, **kw)
1096+ self.implicit_save = True
1097+ self._prev_dict = None
1098+ self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
1099+ if os.path.exists(self.path):
1100+ self.load_previous()
1101+ atexit(self._implicit_save)
1102+
1103+ def load_previous(self, path=None):
1104+ """Load previous copy of config from disk.
1105+
1106+ In normal usage you don't need to call this method directly - it
1107+ is called automatically at object initialization.
1108+
1109+ :param path:
1110+
1111+ File path from which to load the previous config. If `None`,
1112+ config is loaded from the default location. If `path` is
1113+ specified, subsequent `save()` calls will write to the same
1114+ path.
1115+
1116+ """
1117+ self.path = path or self.path
1118+ with open(self.path) as f:
1119+ self._prev_dict = json.load(f)
1120+ for k, v in copy.deepcopy(self._prev_dict).items():
1121+ if k not in self:
1122+ self[k] = v
1123+
1124+ def changed(self, key):
1125+ """Return True if the current value for this key is different from
1126+ the previous value.
1127+
1128+ """
1129+ if self._prev_dict is None:
1130+ return True
1131+ return self.previous(key) != self.get(key)
1132+
1133+ def previous(self, key):
1134+ """Return previous value for this key, or None if there
1135+ is no previous value.
1136+
1137+ """
1138+ if self._prev_dict:
1139+ return self._prev_dict.get(key)
1140+ return None
1141+
1142+ def save(self):
1143+ """Save this config to disk.
1144+
1145+ If the charm is using the :mod:`Services Framework <services.base>`
1146+ or :meth:'@hook <Hooks.hook>' decorator, this
1147+ is called automatically at the end of successful hook execution.
1148+ Otherwise, it should be called directly by user code.
1149+
1150+ To disable automatic saves, set ``implicit_save=False`` on this
1151+ instance.
1152+
1153+ """
1154+ with open(self.path, 'w') as f:
1155+ json.dump(self, f)
1156+
1157+ def _implicit_save(self):
1158+ if self.implicit_save:
1159+ self.save()
1160
1161
1162 @cached
1163@@ -161,9 +354,15 @@ def config(scope=None):
1164 config_cmd_line = ['config-get']
1165 if scope is not None:
1166 config_cmd_line.append(scope)
1167+ else:
1168+ config_cmd_line.append('--all')
1169 config_cmd_line.append('--format=json')
1170 try:
1171- return json.loads(subprocess.check_output(config_cmd_line))
1172+ config_data = json.loads(
1173+ subprocess.check_output(config_cmd_line).decode('UTF-8'))
1174+ if scope is not None:
1175+ return config_data
1176+ return Config(config_data)
1177 except ValueError:
1178 return None
1179
1180@@ -179,30 +378,62 @@ def relation_get(attribute=None, unit=None, rid=None):
1181 if unit:
1182 _args.append(unit)
1183 try:
1184- return json.loads(subprocess.check_output(_args))
1185+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1186 except ValueError:
1187 return None
1188- except CalledProcessError, e:
1189+ except CalledProcessError as e:
1190 if e.returncode == 2:
1191 return None
1192 raise
1193
1194
1195-def relation_set(relation_id=None, relation_settings={}, **kwargs):
1196+def relation_set(relation_id=None, relation_settings=None, **kwargs):
1197 """Set relation information for the current unit"""
1198+ relation_settings = relation_settings if relation_settings else {}
1199 relation_cmd_line = ['relation-set']
1200+ accepts_file = "--file" in subprocess.check_output(
1201+ relation_cmd_line + ["--help"], universal_newlines=True)
1202 if relation_id is not None:
1203 relation_cmd_line.extend(('-r', relation_id))
1204- for k, v in (relation_settings.items() + kwargs.items()):
1205- if v is None:
1206- relation_cmd_line.append('{}='.format(k))
1207- else:
1208- relation_cmd_line.append('{}={}'.format(k, v))
1209- subprocess.check_call(relation_cmd_line)
1210+ settings = relation_settings.copy()
1211+ settings.update(kwargs)
1212+ for key, value in settings.items():
1213+ # Force value to be a string: it always should, but some call
1214+ # sites pass in things like dicts or numbers.
1215+ if value is not None:
1216+ settings[key] = "{}".format(value)
1217+ if accepts_file:
1218+ # --file was introduced in Juju 1.23.2. Use it by default if
1219+ # available, since otherwise we'll break if the relation data is
1220+ # too big. Ideally we should tell relation-set to read the data from
1221+ # stdin, but that feature is broken in 1.23.2: Bug #1454678.
1222+ with tempfile.NamedTemporaryFile(delete=False) as settings_file:
1223+ settings_file.write(yaml.safe_dump(settings).encode("utf-8"))
1224+ subprocess.check_call(
1225+ relation_cmd_line + ["--file", settings_file.name])
1226+ os.remove(settings_file.name)
1227+ else:
1228+ for key, value in settings.items():
1229+ if value is None:
1230+ relation_cmd_line.append('{}='.format(key))
1231+ else:
1232+ relation_cmd_line.append('{}={}'.format(key, value))
1233+ subprocess.check_call(relation_cmd_line)
1234 # Flush cache of any relation-gets for local unit
1235 flush(local_unit())
1236
1237
1238+def relation_clear(r_id=None):
1239+ ''' Clears any relation data already set on relation r_id '''
1240+ settings = relation_get(rid=r_id,
1241+ unit=local_unit())
1242+ for setting in settings:
1243+ if setting not in ['public-address', 'private-address']:
1244+ settings[setting] = None
1245+ relation_set(relation_id=r_id,
1246+ **settings)
1247+
1248+
1249 @cached
1250 def relation_ids(reltype=None):
1251 """A list of relation_ids"""
1252@@ -210,7 +441,8 @@ def relation_ids(reltype=None):
1253 relid_cmd_line = ['relation-ids', '--format=json']
1254 if reltype is not None:
1255 relid_cmd_line.append(reltype)
1256- return json.loads(subprocess.check_output(relid_cmd_line)) or []
1257+ return json.loads(
1258+ subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
1259 return []
1260
1261
1262@@ -221,7 +453,8 @@ def related_units(relid=None):
1263 units_cmd_line = ['relation-list', '--format=json']
1264 if relid is not None:
1265 units_cmd_line.extend(('-r', relid))
1266- return json.loads(subprocess.check_output(units_cmd_line)) or []
1267+ return json.loads(
1268+ subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
1269
1270
1271 @cached
1272@@ -261,21 +494,116 @@ def relations_of_type(reltype=None):
1273
1274
1275 @cached
1276+def metadata():
1277+ """Get the current charm metadata.yaml contents as a python object"""
1278+ with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
1279+ return yaml.safe_load(md)
1280+
1281+
1282+def _metadata_unit(unit):
1283+ """Given the name of a unit (e.g. apache2/0), get the unit charm's
1284+ metadata.yaml. Very similar to metadata() but allows us to inspect
1285+ other units. Unit needs to be co-located, such as a subordinate or
1286+ principal/primary.
1287+
1288+ :returns: metadata.yaml as a python object.
1289+
1290+ """
1291+ basedir = os.sep.join(charm_dir().split(os.sep)[:-2])
1292+ unitdir = 'unit-{}'.format(unit.replace(os.sep, '-'))
1293+ with open(os.path.join(basedir, unitdir, 'charm', 'metadata.yaml')) as md:
1294+ return yaml.safe_load(md)
1295+
1296+
1297+@cached
1298 def relation_types():
1299 """Get a list of relation types supported by this charm"""
1300- charmdir = os.environ.get('CHARM_DIR', '')
1301- mdf = open(os.path.join(charmdir, 'metadata.yaml'))
1302- md = yaml.safe_load(mdf)
1303 rel_types = []
1304+ md = metadata()
1305 for key in ('provides', 'requires', 'peers'):
1306 section = md.get(key)
1307 if section:
1308 rel_types.extend(section.keys())
1309- mdf.close()
1310 return rel_types
1311
1312
1313 @cached
1314+def peer_relation_id():
1315+ '''Get the peers relation id if a peers relation has been joined, else None.'''
1316+ md = metadata()
1317+ section = md.get('peers')
1318+ if section:
1319+ for key in section:
1320+ relids = relation_ids(key)
1321+ if relids:
1322+ return relids[0]
1323+ return None
1324+
1325+
1326+@cached
1327+def relation_to_interface(relation_name):
1328+ """
1329+ Given the name of a relation, return the interface that relation uses.
1330+
1331+ :returns: The interface name, or ``None``.
1332+ """
1333+ return relation_to_role_and_interface(relation_name)[1]
1334+
1335+
1336+@cached
1337+def relation_to_role_and_interface(relation_name):
1338+ """
1339+ Given the name of a relation, return the role and the name of the interface
1340+ that relation uses (where role is one of ``provides``, ``requires``, or ``peers``).
1341+
1342+ :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
1343+ """
1344+ _metadata = metadata()
1345+ for role in ('provides', 'requires', 'peers'):
1346+ interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
1347+ if interface:
1348+ return role, interface
1349+ return None, None
1350+
1351+
1352+@cached
1353+def role_and_interface_to_relations(role, interface_name):
1354+ """
1355+ Given a role and interface name, return a list of relation names for the
1356+ current charm that use that interface under that role (where role is one
1357+ of ``provides``, ``requires``, or ``peers``).
1358+
1359+ :returns: A list of relation names.
1360+ """
1361+ _metadata = metadata()
1362+ results = []
1363+ for relation_name, relation in _metadata.get(role, {}).items():
1364+ if relation['interface'] == interface_name:
1365+ results.append(relation_name)
1366+ return results
1367+
1368+
1369+@cached
1370+def interface_to_relations(interface_name):
1371+ """
1372+ Given an interface, return a list of relation names for the current
1373+ charm that use that interface.
1374+
1375+ :returns: A list of relation names.
1376+ """
1377+ results = []
1378+ for role in ('provides', 'requires', 'peers'):
1379+ results.extend(role_and_interface_to_relations(role, interface_name))
1380+ return results
1381+
1382+
1383+@cached
1384+def charm_name():
1385+ """Get the name of the current charm as is specified on metadata.yaml"""
1386+ return metadata().get('name')
1387+
1388+
1389+@cached
1390 def relations():
1391 """Get a nested dictionary of relation data for all related units"""
1392 rels = {}
1393@@ -325,21 +653,72 @@ def close_port(port, protocol="TCP"):
1394 subprocess.check_call(_args)
1395
1396
1397+def open_ports(start, end, protocol="TCP"):
1398+ """Opens a range of service network ports"""
1399+ _args = ['open-port']
1400+ _args.append('{}-{}/{}'.format(start, end, protocol))
1401+ subprocess.check_call(_args)
1402+
1403+
1404+def close_ports(start, end, protocol="TCP"):
1405+ """Close a range of service network ports"""
1406+ _args = ['close-port']
1407+ _args.append('{}-{}/{}'.format(start, end, protocol))
1408+ subprocess.check_call(_args)
1409+
1410+
1411 @cached
1412 def unit_get(attribute):
1413 """Get the unit ID for the remote unit"""
1414 _args = ['unit-get', '--format=json', attribute]
1415 try:
1416- return json.loads(subprocess.check_output(_args))
1417+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1418 except ValueError:
1419 return None
1420
1421
1422+def unit_public_ip():
1423+ """Get this unit's public IP address"""
1424+ return unit_get('public-address')
1425+
1426+
1427 def unit_private_ip():
1428 """Get this unit's private IP address"""
1429 return unit_get('private-address')
1430
1431
1432+@cached
1433+def storage_get(attribute=None, storage_id=None):
1434+ """Get storage attributes"""
1435+ _args = ['storage-get', '--format=json']
1436+ if storage_id:
1437+ _args.extend(('-s', storage_id))
1438+ if attribute:
1439+ _args.append(attribute)
1440+ try:
1441+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1442+ except ValueError:
1443+ return None
1444+
1445+
1446+@cached
1447+def storage_list(storage_name=None):
1448+ """List the storage IDs for the unit"""
1449+ _args = ['storage-list', '--format=json']
1450+ if storage_name:
1451+ _args.append(storage_name)
1452+ try:
1453+ return json.loads(subprocess.check_output(_args).decode('UTF-8'))
1454+ except ValueError:
1455+ return None
1456+ except OSError as e:
1457+ import errno
1458+ if e.errno == errno.ENOENT:
1459+ # storage-list does not exist
1460+ return []
1461+ raise
1462+
1463+
1464 class UnregisteredHookError(Exception):
1465 """Raised when an undefined hook is called"""
1466 pass
1467@@ -348,37 +727,50 @@ class UnregisteredHookError(Exception):
1468 class Hooks(object):
1469 """A convenient handler for hook functions.
1470
1471- Example:
1472+ Example::
1473+
1474 hooks = Hooks()
1475
1476 # register a hook, taking its name from the function name
1477 @hooks.hook()
1478 def install():
1479- ...
1480+ pass # your code here
1481
1482 # register a hook, providing a custom hook name
1483 @hooks.hook("config-changed")
1484 def config_changed():
1485- ...
1486+ pass # your code here
1487
1488 if __name__ == "__main__":
1489 # execute a hook based on the name the program is called by
1490 hooks.execute(sys.argv)
1491 """
1492
1493- def __init__(self):
1494+ def __init__(self, config_save=None):
1495 super(Hooks, self).__init__()
1496 self._hooks = {}
1497
1498+ # For unknown reasons, we allow the Hooks constructor to override
1499+ # config().implicit_save.
1500+ if config_save is not None:
1501+ config().implicit_save = config_save
1502+
1503 def register(self, name, function):
1504 """Register a hook"""
1505 self._hooks[name] = function
1506
1507 def execute(self, args):
1508 """Execute a registered hook based on args[0]"""
1509+ _run_atstart()
1510 hook_name = os.path.basename(args[0])
1511 if hook_name in self._hooks:
1512- self._hooks[hook_name]()
1513+ try:
1514+ self._hooks[hook_name]()
1515+ except SystemExit as x:
1516+ if x.code is None or x.code == 0:
1517+ _run_atexit()
1518+ raise
1519+ _run_atexit()
1520 else:
1521 raise UnregisteredHookError(hook_name)
1522
1523@@ -398,4 +790,319 @@ class Hooks(object):
1524
1525 def charm_dir():
1526 """Return the root directory of the current charm"""
1527+ d = os.environ.get('JUJU_CHARM_DIR')
1528+ if d is not None:
1529+ return d
1530 return os.environ.get('CHARM_DIR')
1531+
1532+
1533+@cached
1534+def action_get(key=None):
1535+ """Gets the value of an action parameter, or all key/value param pairs"""
1536+ cmd = ['action-get']
1537+ if key is not None:
1538+ cmd.append(key)
1539+ cmd.append('--format=json')
1540+ action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1541+ return action_data
1542+
1543+
1544+def action_set(values):
1545+ """Sets the values to be returned after the action finishes"""
1546+ cmd = ['action-set']
1547+ for k, v in list(values.items()):
1548+ cmd.append('{}={}'.format(k, v))
1549+ subprocess.check_call(cmd)
1550+
1551+
1552+def action_fail(message):
1553+ """Sets the action status to failed and sets the error message.
1554+
1555+ The results set by action_set are preserved."""
1556+ subprocess.check_call(['action-fail', message])
1557+
1558+
1559+def action_name():
1560+ """Get the name of the currently executing action."""
1561+ return os.environ.get('JUJU_ACTION_NAME')
1562+
1563+
1564+def action_uuid():
1565+ """Get the UUID of the currently executing action."""
1566+ return os.environ.get('JUJU_ACTION_UUID')
1567+
1568+
1569+def action_tag():
1570+ """Get the tag for the currently executing action."""
1571+ return os.environ.get('JUJU_ACTION_TAG')
1572+
1573+
1574+def status_set(workload_state, message):
1575+ """Set the workload state with a message
1576+
1577+ Use status-set to set the workload state with a message which is visible
1578+ to the user via juju status. If the status-set command is not found then
1579+ assume this is juju < 1.23 and juju-log the message unstead.
1580+
1581+ workload_state -- valid juju workload state.
1582+ message -- status update message
1583+ """
1584+ valid_states = ['maintenance', 'blocked', 'waiting', 'active']
1585+ if workload_state not in valid_states:
1586+ raise ValueError(
1587+ '{!r} is not a valid workload state'.format(workload_state)
1588+ )
1589+ cmd = ['status-set', workload_state, message]
1590+ try:
1591+ ret = subprocess.call(cmd)
1592+ if ret == 0:
1593+ return
1594+ except OSError as e:
1595+ if e.errno != errno.ENOENT:
1596+ raise
1597+ log_message = 'status-set failed: {} {}'.format(workload_state,
1598+ message)
1599+ log(log_message, level='INFO')
1600+
1601+
1602+def status_get():
1603+ """Retrieve the previously set juju workload state and message
1604+
1605+ If the status-get command is not found then assume this is juju < 1.23 and
1606+ return 'unknown', ""
1607+
1608+ """
1609+ cmd = ['status-get', "--format=json", "--include-data"]
1610+ try:
1611+ raw_status = subprocess.check_output(cmd)
1612+ except OSError as e:
1613+ if e.errno == errno.ENOENT:
1614+ return ('unknown', "")
1615+ else:
1616+ raise
1617+ else:
1618+ status = json.loads(raw_status.decode("UTF-8"))
1619+ return (status["status"], status["message"])
1620+
1621+
1622+def translate_exc(from_exc, to_exc):
1623+ def inner_translate_exc1(f):
1624+ @wraps(f)
1625+ def inner_translate_exc2(*args, **kwargs):
1626+ try:
1627+ return f(*args, **kwargs)
1628+ except from_exc:
1629+ raise to_exc
1630+
1631+ return inner_translate_exc2
1632+
1633+ return inner_translate_exc1
1634+
1635+
1636+def application_version_set(version):
1637+ """Charm authors may trigger this command from any hook to output what
1638+ version of the application is running. This could be a package version,
1639+ for instance postgres version 9.5. It could also be a build number or
1640+ version control revision identifier, for instance git sha 6fb7ba68. """
1641+
1642+ cmd = ['application-version-set']
1643+ cmd.append(version)
1644+ try:
1645+ subprocess.check_call(cmd)
1646+ except OSError:
1647+ log("Application Version: {}".format(version))
1648+
1649+
1650+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1651+def is_leader():
1652+ """Does the current unit hold the juju leadership
1653+
1654+ Uses juju to determine whether the current unit is the leader of its peers
1655+ """
1656+ cmd = ['is-leader', '--format=json']
1657+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1658+
1659+
1660+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1661+def leader_get(attribute=None):
1662+ """Juju leader get value(s)"""
1663+ cmd = ['leader-get', '--format=json'] + [attribute or '-']
1664+ return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
1665+
1666+
1667+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1668+def leader_set(settings=None, **kwargs):
1669+ """Juju leader set value(s)"""
1670+ # Don't log secrets.
1671+ # log("Juju leader-set '%s'" % (settings), level=DEBUG)
1672+ cmd = ['leader-set']
1673+ settings = settings or {}
1674+ settings.update(kwargs)
1675+ for k, v in settings.items():
1676+ if v is None:
1677+ cmd.append('{}='.format(k))
1678+ else:
1679+ cmd.append('{}={}'.format(k, v))
1680+ subprocess.check_call(cmd)
1681+
1682+
1683+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1684+def payload_register(ptype, klass, pid):
1685+ """ is used while a hook is running to let Juju know that a
1686+ payload has been started."""
1687+ cmd = ['payload-register']
1688+ for x in [ptype, klass, pid]:
1689+ cmd.append(x)
1690+ subprocess.check_call(cmd)
1691+
1692+
1693+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1694+def payload_unregister(klass, pid):
1695+ """ is used while a hook is running to let Juju know
1696+ that a payload has been manually stopped. The <class> and <id> provided
1697+ must match a payload that has been previously registered with juju using
1698+ payload-register."""
1699+ cmd = ['payload-unregister']
1700+ for x in [klass, pid]:
1701+ cmd.append(x)
1702+ subprocess.check_call(cmd)
1703+
1704+
1705+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1706+def payload_status_set(klass, pid, status):
1707+ """is used to update the current status of a registered payload.
1708+ The <class> and <id> provided must match a payload that has been previously
1709+ registered with juju using payload-register. The <status> must be one of the
1710+ follow: starting, started, stopping, stopped"""
1711+ cmd = ['payload-status-set']
1712+ for x in [klass, pid, status]:
1713+ cmd.append(x)
1714+ subprocess.check_call(cmd)
1715+
1716+
1717+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1718+def resource_get(name):
1719+ """used to fetch the resource path of the given name.
1720+
1721+ <name> must match a name of defined resource in metadata.yaml
1722+
1723+ returns either a path or False if resource not available
1724+ """
1725+ if not name:
1726+ return False
1727+
1728+ cmd = ['resource-get', name]
1729+ try:
1730+ return subprocess.check_output(cmd).decode('UTF-8')
1731+ except subprocess.CalledProcessError:
1732+ return False
1733+
1734+
1735+@cached
1736+def juju_version():
1737+ """Full version string (eg. '1.23.3.1-trusty-amd64')"""
1738+ # Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
1739+ jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
1740+ return subprocess.check_output([jujud, 'version'],
1741+ universal_newlines=True).strip()
1742+
1743+
1744+@cached
1745+def has_juju_version(minimum_version):
1746+ """Return True if the Juju version is at least the provided version"""
1747+ return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
1748+
1749+
1750+_atexit = []
1751+_atstart = []
1752+
1753+
1754+def atstart(callback, *args, **kwargs):
1755+ '''Schedule a callback to run before the main hook.
1756+
1757+ Callbacks are run in the order they were added.
1758+
1759+ This is useful for modules and classes to perform initialization
1760+ and inject behavior. In particular:
1761+
1762+ - Run common code before all of your hooks, such as logging
1763+ the hook name or interesting relation data.
1764+ - Defer object or module initialization that requires a hook
1765+ context until we know there actually is a hook context,
1766+ making testing easier.
1767+ - Rather than requiring charm authors to include boilerplate to
1768+ invoke your helper's behavior, have it run automatically if
1769+ your object is instantiated or module imported.
1770+
1771+ This is not at all useful after your hook framework as been launched.
1772+ '''
1773+ global _atstart
1774+ _atstart.append((callback, args, kwargs))
1775+
1776+
1777+def atexit(callback, *args, **kwargs):
1778+ '''Schedule a callback to run on successful hook completion.
1779+
1780+ Callbacks are run in the reverse order that they were added.'''
1781+ _atexit.append((callback, args, kwargs))
1782+
1783+
1784+def _run_atstart():
1785+ '''Hook frameworks must invoke this before running the main hook body.'''
1786+ global _atstart
1787+ for callback, args, kwargs in _atstart:
1788+ callback(*args, **kwargs)
1789+ del _atstart[:]
1790+
1791+
1792+def _run_atexit():
1793+ '''Hook frameworks must invoke this after the main hook body has
1794+ successfully completed. Do not invoke it if the hook fails.'''
1795+ global _atexit
1796+ for callback, args, kwargs in reversed(_atexit):
1797+ callback(*args, **kwargs)
1798+ del _atexit[:]
1799+
1800+
1801+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
1802+def network_get_primary_address(binding):
1803+ '''
1804+ Retrieve the primary network address for a named binding
1805+
1806+ :param binding: string. The name of a relation of extra-binding
1807+ :return: string. The primary IP address for the named binding
1808+ :raise: NotImplementedError if run on Juju < 2.0
1809+ '''
1810+ cmd = ['network-get', '--primary-address', binding]
1811+ return subprocess.check_output(cmd).decode('UTF-8').strip()
1812+
1813+
1814+def add_metric(*args, **kwargs):
1815+ """Add metric values. Values may be expressed with keyword arguments. For
1816+ metric names containing dashes, these may be expressed as one or more
1817+ 'key=value' positional arguments. May only be called from the collect-metrics
1818+ hook."""
1819+ _args = ['add-metric']
1820+ _kvpairs = []
1821+ _kvpairs.extend(args)
1822+ _kvpairs.extend(['{}={}'.format(k, v) for k, v in kwargs.items()])
1823+ _args.extend(sorted(_kvpairs))
1824+ try:
1825+ subprocess.check_call(_args)
1826+ return
1827+ except EnvironmentError as e:
1828+ if e.errno != errno.ENOENT:
1829+ raise
1830+ log_message = 'add-metric failed: {}'.format(' '.join(_kvpairs))
1831+ log(log_message, level='INFO')
1832+
1833+
1834+def meter_status():
1835+ """Get the meter status, if running in the meter-status-changed hook."""
1836+ return os.environ.get('JUJU_METER_STATUS')
1837+
1838+
1839+def meter_info():
1840+ """Get the meter status information, if running in the meter-status-changed
1841+ hook."""
1842+ return os.environ.get('JUJU_METER_INFO')
1843diff --git a/hooks/charmhelpers/core/host.py b/hooks/charmhelpers/core/host.py
1844index cfd2684..5656e2f 100644
1845--- a/hooks/charmhelpers/core/host.py
1846+++ b/hooks/charmhelpers/core/host.py
1847@@ -1,3 +1,17 @@
1848+# Copyright 2014-2015 Canonical Limited.
1849+#
1850+# Licensed under the Apache License, Version 2.0 (the "License");
1851+# you may not use this file except in compliance with the License.
1852+# You may obtain a copy of the License at
1853+#
1854+# http://www.apache.org/licenses/LICENSE-2.0
1855+#
1856+# Unless required by applicable law or agreed to in writing, software
1857+# distributed under the License is distributed on an "AS IS" BASIS,
1858+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1859+# See the License for the specific language governing permissions and
1860+# limitations under the License.
1861+
1862 """Tools for working with the host system"""
1863 # Copyright 2012 Canonical Ltd.
1864 #
1865@@ -6,68 +20,332 @@
1866 # Matthew Wedgwood <matthew.wedgwood@canonical.com>
1867
1868 import os
1869+import re
1870 import pwd
1871+import glob
1872 import grp
1873 import random
1874 import string
1875 import subprocess
1876 import hashlib
1877+import functools
1878+import itertools
1879+import six
1880
1881+from contextlib import contextmanager
1882 from collections import OrderedDict
1883+from .hookenv import log, DEBUG
1884+from .fstab import Fstab
1885+from charmhelpers.osplatform import get_platform
1886+
1887+__platform__ = get_platform()
1888+if __platform__ == "ubuntu":
1889+ from charmhelpers.core.host_factory.ubuntu import (
1890+ service_available,
1891+ add_new_group,
1892+ lsb_release,
1893+ cmp_pkgrevno,
1894+ CompareHostReleases,
1895+ ) # flake8: noqa -- ignore F401 for this import
1896+elif __platform__ == "centos":
1897+ from charmhelpers.core.host_factory.centos import (
1898+ service_available,
1899+ add_new_group,
1900+ lsb_release,
1901+ cmp_pkgrevno,
1902+ CompareHostReleases,
1903+ ) # flake8: noqa -- ignore F401 for this import
1904+
1905+UPDATEDB_PATH = '/etc/updatedb.conf'
1906+
1907+def service_start(service_name, **kwargs):
1908+ """Start a system service.
1909+
1910+ The specified service name is managed via the system level init system.
1911+ Some init systems (e.g. upstart) require that additional arguments be
1912+ provided in order to directly control service instances whereas other init
1913+ systems allow for addressing instances of a service directly by name (e.g.
1914+ systemd).
1915+
1916+ The kwargs allow for the additional parameters to be passed to underlying
1917+ init systems for those systems which require/allow for them. For example,
1918+ the ceph-osd upstart script requires the id parameter to be passed along
1919+ in order to identify which running daemon should be reloaded. The follow-
1920+ ing example stops the ceph-osd service for instance id=4:
1921+
1922+ service_stop('ceph-osd', id=4)
1923+
1924+ :param service_name: the name of the service to stop
1925+ :param **kwargs: additional parameters to pass to the init system when
1926+ managing services. These will be passed as key=value
1927+ parameters to the init system's commandline. kwargs
1928+ are ignored for systemd enabled systems.
1929+ """
1930+ return service('start', service_name, **kwargs)
1931
1932-from hookenv import log
1933
1934+def service_stop(service_name, **kwargs):
1935+ """Stop a system service.
1936+
1937+ The specified service name is managed via the system level init system.
1938+ Some init systems (e.g. upstart) require that additional arguments be
1939+ provided in order to directly control service instances whereas other init
1940+ systems allow for addressing instances of a service directly by name (e.g.
1941+ systemd).
1942+
1943+ The kwargs allow for the additional parameters to be passed to underlying
1944+ init systems for those systems which require/allow for them. For example,
1945+ the ceph-osd upstart script requires the id parameter to be passed along
1946+ in order to identify which running daemon should be reloaded. The follow-
1947+ ing example stops the ceph-osd service for instance id=4:
1948+
1949+ service_stop('ceph-osd', id=4)
1950+
1951+ :param service_name: the name of the service to stop
1952+ :param **kwargs: additional parameters to pass to the init system when
1953+ managing services. These will be passed as key=value
1954+ parameters to the init system's commandline. kwargs
1955+ are ignored for systemd enabled systems.
1956+ """
1957+ return service('stop', service_name, **kwargs)
1958
1959-def service_start(service_name):
1960- """Start a system service"""
1961- return service('start', service_name)
1962
1963+def service_restart(service_name, **kwargs):
1964+ """Restart a system service.
1965
1966-def service_stop(service_name):
1967- """Stop a system service"""
1968- return service('stop', service_name)
1969+ The specified service name is managed via the system level init system.
1970+ Some init systems (e.g. upstart) require that additional arguments be
1971+ provided in order to directly control service instances whereas other init
1972+ systems allow for addressing instances of a service directly by name (e.g.
1973+ systemd).
1974
1975+ The kwargs allow for the additional parameters to be passed to underlying
1976+ init systems for those systems which require/allow for them. For example,
1977+ the ceph-osd upstart script requires the id parameter to be passed along
1978+ in order to identify which running daemon should be restarted. The follow-
1979+ ing example restarts the ceph-osd service for instance id=4:
1980
1981-def service_restart(service_name):
1982- """Restart a system service"""
1983+ service_restart('ceph-osd', id=4)
1984+
1985+ :param service_name: the name of the service to restart
1986+ :param **kwargs: additional parameters to pass to the init system when
1987+ managing services. These will be passed as key=value
1988+ parameters to the init system's commandline. kwargs
1989+ are ignored for init systems not allowing additional
1990+ parameters via the commandline (systemd).
1991+ """
1992 return service('restart', service_name)
1993
1994
1995-def service_reload(service_name, restart_on_failure=False):
1996- """Reload a system service, optionally falling back to restart if reload fails"""
1997- service_result = service('reload', service_name)
1998+def service_reload(service_name, restart_on_failure=False, **kwargs):
1999+ """Reload a system service, optionally falling back to restart if
2000+ reload fails.
2001+
2002+ The specified service name is managed via the system level init system.
2003+ Some init systems (e.g. upstart) require that additional arguments be
2004+ provided in order to directly control service instances whereas other init
2005+ systems allow for addressing instances of a service directly by name (e.g.
2006+ systemd).
2007+
2008+ The kwargs allow for the additional parameters to be passed to underlying
2009+ init systems for those systems which require/allow for them. For example,
2010+ the ceph-osd upstart script requires the id parameter to be passed along
2011+ in order to identify which running daemon should be reloaded. The follow-
2012+ ing example restarts the ceph-osd service for instance id=4:
2013+
2014+ service_reload('ceph-osd', id=4)
2015+
2016+ :param service_name: the name of the service to reload
2017+ :param restart_on_failure: boolean indicating whether to fallback to a
2018+ restart if the reload fails.
2019+ :param **kwargs: additional parameters to pass to the init system when
2020+ managing services. These will be passed as key=value
2021+ parameters to the init system's commandline. kwargs
2022+ are ignored for init systems not allowing additional
2023+ parameters via the commandline (systemd).
2024+ """
2025+ service_result = service('reload', service_name, **kwargs)
2026 if not service_result and restart_on_failure:
2027- service_result = service('restart', service_name)
2028+ service_result = service('restart', service_name, **kwargs)
2029 return service_result
2030
2031
2032-def service(action, service_name):
2033- """Control a system service"""
2034- cmd = ['service', service_name, action]
2035+def service_pause(service_name, init_dir="/etc/init", initd_dir="/etc/init.d",
2036+ **kwargs):
2037+ """Pause a system service.
2038+
2039+ Stop it, and prevent it from starting again at boot.
2040+
2041+ :param service_name: the name of the service to pause
2042+ :param init_dir: path to the upstart init directory
2043+ :param initd_dir: path to the sysv init directory
2044+ :param **kwargs: additional parameters to pass to the init system when
2045+ managing services. These will be passed as key=value
2046+ parameters to the init system's commandline. kwargs
2047+ are ignored for init systems which do not support
2048+ key=value arguments via the commandline.
2049+ """
2050+ stopped = True
2051+ if service_running(service_name, **kwargs):
2052+ stopped = service_stop(service_name, **kwargs)
2053+ upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
2054+ sysv_file = os.path.join(initd_dir, service_name)
2055+ if init_is_systemd():
2056+ service('disable', service_name)
2057+ service('mask', service_name)
2058+ elif os.path.exists(upstart_file):
2059+ override_path = os.path.join(
2060+ init_dir, '{}.override'.format(service_name))
2061+ with open(override_path, 'w') as fh:
2062+ fh.write("manual\n")
2063+ elif os.path.exists(sysv_file):
2064+ subprocess.check_call(["update-rc.d", service_name, "disable"])
2065+ else:
2066+ raise ValueError(
2067+ "Unable to detect {0} as SystemD, Upstart {1} or"
2068+ " SysV {2}".format(
2069+ service_name, upstart_file, sysv_file))
2070+ return stopped
2071+
2072+
2073+def service_resume(service_name, init_dir="/etc/init",
2074+ initd_dir="/etc/init.d", **kwargs):
2075+ """Resume a system service.
2076+
2077+ Reenable starting again at boot. Start the service.
2078+
2079+ :param service_name: the name of the service to resume
2080+ :param init_dir: the path to the init dir
2081+ :param initd dir: the path to the initd dir
2082+ :param **kwargs: additional parameters to pass to the init system when
2083+ managing services. These will be passed as key=value
2084+ parameters to the init system's commandline. kwargs
2085+ are ignored for systemd enabled systems.
2086+ """
2087+ upstart_file = os.path.join(init_dir, "{}.conf".format(service_name))
2088+ sysv_file = os.path.join(initd_dir, service_name)
2089+ if init_is_systemd():
2090+ service('unmask', service_name)
2091+ service('enable', service_name)
2092+ elif os.path.exists(upstart_file):
2093+ override_path = os.path.join(
2094+ init_dir, '{}.override'.format(service_name))
2095+ if os.path.exists(override_path):
2096+ os.unlink(override_path)
2097+ elif os.path.exists(sysv_file):
2098+ subprocess.check_call(["update-rc.d", service_name, "enable"])
2099+ else:
2100+ raise ValueError(
2101+ "Unable to detect {0} as SystemD, Upstart {1} or"
2102+ " SysV {2}".format(
2103+ service_name, upstart_file, sysv_file))
2104+ started = service_running(service_name, **kwargs)
2105+
2106+ if not started:
2107+ started = service_start(service_name, **kwargs)
2108+ return started
2109+
2110+
2111+def service(action, service_name, **kwargs):
2112+ """Control a system service.
2113+
2114+ :param action: the action to take on the service
2115+ :param service_name: the name of the service to perform th action on
2116+ :param **kwargs: additional params to be passed to the service command in
2117+ the form of key=value.
2118+ """
2119+ if init_is_systemd():
2120+ cmd = ['systemctl', action, service_name]
2121+ else:
2122+ cmd = ['service', service_name, action]
2123+ for key, value in six.iteritems(kwargs):
2124+ parameter = '%s=%s' % (key, value)
2125+ cmd.append(parameter)
2126 return subprocess.call(cmd) == 0
2127
2128
2129-def service_running(service):
2130- """Determine whether a system service is running"""
2131- try:
2132- output = subprocess.check_output(['service', service, 'status'])
2133- except subprocess.CalledProcessError:
2134- return False
2135+_UPSTART_CONF = "/etc/init/{}.conf"
2136+_INIT_D_CONF = "/etc/init.d/{}"
2137+
2138+
2139+def service_running(service_name, **kwargs):
2140+ """Determine whether a system service is running.
2141+
2142+ :param service_name: the name of the service
2143+ :param **kwargs: additional args to pass to the service command. This is
2144+ used to pass additional key=value arguments to the
2145+ service command line for managing specific instance
2146+ units (e.g. service ceph-osd status id=2). The kwargs
2147+ are ignored in systemd services.
2148+ """
2149+ if init_is_systemd():
2150+ return service('is-active', service_name)
2151 else:
2152- if ("start/running" in output or "is running" in output):
2153- return True
2154- else:
2155- return False
2156+ if os.path.exists(_UPSTART_CONF.format(service_name)):
2157+ try:
2158+ cmd = ['status', service_name]
2159+ for key, value in six.iteritems(kwargs):
2160+ parameter = '%s=%s' % (key, value)
2161+ cmd.append(parameter)
2162+ output = subprocess.check_output(cmd,
2163+ stderr=subprocess.STDOUT).decode('UTF-8')
2164+ except subprocess.CalledProcessError:
2165+ return False
2166+ else:
2167+ # This works for upstart scripts where the 'service' command
2168+ # returns a consistent string to represent running
2169+ # 'start/running'
2170+ if ("start/running" in output or
2171+ "is running" in output or
2172+ "up and running" in output):
2173+ return True
2174+ elif os.path.exists(_INIT_D_CONF.format(service_name)):
2175+ # Check System V scripts init script return codes
2176+ return service('status', service_name)
2177+ return False
2178+
2179
2180+SYSTEMD_SYSTEM = '/run/systemd/system'
2181
2182-def adduser(username, password=None, shell='/bin/bash', system_user=False):
2183- """Add a user to the system"""
2184+
2185+def init_is_systemd():
2186+ """Return True if the host system uses systemd, False otherwise."""
2187+ if lsb_release()['DISTRIB_CODENAME'] == 'trusty':
2188+ return False
2189+ return os.path.isdir(SYSTEMD_SYSTEM)
2190+
2191+
2192+def adduser(username, password=None, shell='/bin/bash',
2193+ system_user=False, primary_group=None,
2194+ secondary_groups=None, uid=None, home_dir=None):
2195+ """Add a user to the system.
2196+
2197+ Will log but otherwise succeed if the user already exists.
2198+
2199+ :param str username: Username to create
2200+ :param str password: Password for user; if ``None``, create a system user
2201+ :param str shell: The default shell for the user
2202+ :param bool system_user: Whether to create a login or system user
2203+ :param str primary_group: Primary group for user; defaults to username
2204+ :param list secondary_groups: Optional list of additional groups
2205+ :param int uid: UID for user being created
2206+ :param str home_dir: Home directory for user
2207+
2208+ :returns: The password database entry struct, as returned by `pwd.getpwnam`
2209+ """
2210 try:
2211 user_info = pwd.getpwnam(username)
2212 log('user {0} already exists!'.format(username))
2213+ if uid:
2214+ user_info = pwd.getpwuid(int(uid))
2215+ log('user with uid {0} already exists!'.format(uid))
2216 except KeyError:
2217 log('creating user {0}'.format(username))
2218 cmd = ['useradd']
2219+ if uid:
2220+ cmd.extend(['--uid', str(uid)])
2221+ if home_dir:
2222+ cmd.extend(['--home', str(home_dir)])
2223 if system_user or password is None:
2224 cmd.append('--system')
2225 else:
2226@@ -76,32 +354,104 @@ def adduser(username, password=None, shell='/bin/bash', system_user=False):
2227 '--shell', shell,
2228 '--password', password,
2229 ])
2230+ if not primary_group:
2231+ try:
2232+ grp.getgrnam(username)
2233+ primary_group = username # avoid "group exists" error
2234+ except KeyError:
2235+ pass
2236+ if primary_group:
2237+ cmd.extend(['-g', primary_group])
2238+ if secondary_groups:
2239+ cmd.extend(['-G', ','.join(secondary_groups)])
2240 cmd.append(username)
2241 subprocess.check_call(cmd)
2242 user_info = pwd.getpwnam(username)
2243 return user_info
2244
2245
2246+def user_exists(username):
2247+ """Check if a user exists"""
2248+ try:
2249+ pwd.getpwnam(username)
2250+ user_exists = True
2251+ except KeyError:
2252+ user_exists = False
2253+ return user_exists
2254+
2255+
2256+def uid_exists(uid):
2257+ """Check if a uid exists"""
2258+ try:
2259+ pwd.getpwuid(uid)
2260+ uid_exists = True
2261+ except KeyError:
2262+ uid_exists = False
2263+ return uid_exists
2264+
2265+
2266+def group_exists(groupname):
2267+ """Check if a group exists"""
2268+ try:
2269+ grp.getgrnam(groupname)
2270+ group_exists = True
2271+ except KeyError:
2272+ group_exists = False
2273+ return group_exists
2274+
2275+
2276+def gid_exists(gid):
2277+ """Check if a gid exists"""
2278+ try:
2279+ grp.getgrgid(gid)
2280+ gid_exists = True
2281+ except KeyError:
2282+ gid_exists = False
2283+ return gid_exists
2284+
2285+
2286+def add_group(group_name, system_group=False, gid=None):
2287+ """Add a group to the system
2288+
2289+ Will log but otherwise succeed if the group already exists.
2290+
2291+ :param str group_name: group to create
2292+ :param bool system_group: Create system group
2293+ :param int gid: GID for user being created
2294+
2295+ :returns: The password database entry struct, as returned by `grp.getgrnam`
2296+ """
2297+ try:
2298+ group_info = grp.getgrnam(group_name)
2299+ log('group {0} already exists!'.format(group_name))
2300+ if gid:
2301+ group_info = grp.getgrgid(gid)
2302+ log('group with gid {0} already exists!'.format(gid))
2303+ except KeyError:
2304+ log('creating group {0}'.format(group_name))
2305+ add_new_group(group_name, system_group, gid)
2306+ group_info = grp.getgrnam(group_name)
2307+ return group_info
2308+
2309+
2310 def add_user_to_group(username, group):
2311 """Add a user to a group"""
2312- cmd = [
2313- 'gpasswd', '-a',
2314- username,
2315- group
2316- ]
2317+ cmd = ['gpasswd', '-a', username, group]
2318 log("Adding user {} to group {}".format(username, group))
2319 subprocess.check_call(cmd)
2320
2321
2322-def rsync(from_path, to_path, flags='-r', options=None):
2323+def rsync(from_path, to_path, flags='-r', options=None, timeout=None):
2324 """Replicate the contents of a path"""
2325 options = options or ['--delete', '--executability']
2326 cmd = ['/usr/bin/rsync', flags]
2327+ if timeout:
2328+ cmd = ['timeout', str(timeout)] + cmd
2329 cmd.extend(options)
2330 cmd.append(from_path)
2331 cmd.append(to_path)
2332 log(" ".join(cmd))
2333- return subprocess.check_output(cmd).strip()
2334+ return subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode('UTF-8').strip()
2335
2336
2337 def symlink(source, destination):
2338@@ -116,34 +466,71 @@ def symlink(source, destination):
2339 subprocess.check_call(cmd)
2340
2341
2342-def mkdir(path, owner='root', group='root', perms=0555, force=False):
2343+def mkdir(path, owner='root', group='root', perms=0o555, force=False):
2344 """Create a directory"""
2345 log("Making dir {} {}:{} {:o}".format(path, owner, group,
2346 perms))
2347 uid = pwd.getpwnam(owner).pw_uid
2348 gid = grp.getgrnam(group).gr_gid
2349 realpath = os.path.abspath(path)
2350- if os.path.exists(realpath):
2351- if force and not os.path.isdir(realpath):
2352+ path_exists = os.path.exists(realpath)
2353+ if path_exists and force:
2354+ if not os.path.isdir(realpath):
2355 log("Removing non-directory file {} prior to mkdir()".format(path))
2356 os.unlink(realpath)
2357- else:
2358+ os.makedirs(realpath, perms)
2359+ elif not path_exists:
2360 os.makedirs(realpath, perms)
2361 os.chown(realpath, uid, gid)
2362+ os.chmod(realpath, perms)
2363
2364
2365-def write_file(path, content, owner='root', group='root', perms=0444):
2366- """Create or overwrite a file with the contents of a string"""
2367- log("Writing file {} {}:{} {:o}".format(path, owner, group, perms))
2368+def write_file(path, content, owner='root', group='root', perms=0o444):
2369+ """Create or overwrite a file with the contents of a byte string."""
2370 uid = pwd.getpwnam(owner).pw_uid
2371 gid = grp.getgrnam(group).gr_gid
2372- with open(path, 'w') as target:
2373- os.fchown(target.fileno(), uid, gid)
2374- os.fchmod(target.fileno(), perms)
2375- target.write(content)
2376-
2377-
2378-def mount(device, mountpoint, options=None, persist=False):
2379+ # lets see if we can grab the file and compare the context, to avoid doing
2380+ # a write.
2381+ existing_content = None
2382+ existing_uid, existing_gid = None, None
2383+ try:
2384+ with open(path, 'rb') as target:
2385+ existing_content = target.read()
2386+ stat = os.stat(path)
2387+ existing_uid, existing_gid = stat.st_uid, stat.st_gid
2388+ except:
2389+ pass
2390+ if content != existing_content:
2391+ log("Writing file {} {}:{} {:o}".format(path, owner, group, perms),
2392+ level=DEBUG)
2393+ with open(path, 'wb') as target:
2394+ os.fchown(target.fileno(), uid, gid)
2395+ os.fchmod(target.fileno(), perms)
2396+ target.write(content)
2397+ return
2398+ # the contents were the same, but we might still need to change the
2399+ # ownership.
2400+ if existing_uid != uid:
2401+ log("Changing uid on already existing content: {} -> {}"
2402+ .format(existing_uid, uid), level=DEBUG)
2403+ os.chown(path, uid, -1)
2404+ if existing_gid != gid:
2405+ log("Changing gid on already existing content: {} -> {}"
2406+ .format(existing_gid, gid), level=DEBUG)
2407+ os.chown(path, -1, gid)
2408+
2409+
2410+def fstab_remove(mp):
2411+ """Remove the given mountpoint entry from /etc/fstab"""
2412+ return Fstab.remove_by_mountpoint(mp)
2413+
2414+
2415+def fstab_add(dev, mp, fs, options=None):
2416+ """Adds the given device entry to the /etc/fstab file"""
2417+ return Fstab.add(dev, mp, fs, options=options)
2418+
2419+
2420+def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
2421 """Mount a filesystem at a particular mountpoint"""
2422 cmd_args = ['mount']
2423 if options is not None:
2424@@ -151,12 +538,12 @@ def mount(device, mountpoint, options=None, persist=False):
2425 cmd_args.extend([device, mountpoint])
2426 try:
2427 subprocess.check_output(cmd_args)
2428- except subprocess.CalledProcessError, e:
2429+ except subprocess.CalledProcessError as e:
2430 log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
2431 return False
2432+
2433 if persist:
2434- # TODO: update fstab
2435- pass
2436+ return fstab_add(device, mountpoint, filesystem, options=options)
2437 return True
2438
2439
2440@@ -165,12 +552,12 @@ def umount(mountpoint, persist=False):
2441 cmd_args = ['umount', mountpoint]
2442 try:
2443 subprocess.check_output(cmd_args)
2444- except subprocess.CalledProcessError, e:
2445+ except subprocess.CalledProcessError as e:
2446 log('Error unmounting {}\n{}'.format(mountpoint, e.output))
2447 return False
2448+
2449 if persist:
2450- # TODO: update fstab
2451- pass
2452+ return fstab_remove(mountpoint)
2453 return True
2454
2455
2456@@ -183,102 +570,240 @@ def mounts():
2457 return system_mounts
2458
2459
2460-def file_hash(path):
2461- """Generate a md5 hash of the contents of 'path' or None if not found """
2462+def fstab_mount(mountpoint):
2463+ """Mount filesystem using fstab"""
2464+ cmd_args = ['mount', mountpoint]
2465+ try:
2466+ subprocess.check_output(cmd_args)
2467+ except subprocess.CalledProcessError as e:
2468+ log('Error unmounting {}\n{}'.format(mountpoint, e.output))
2469+ return False
2470+ return True
2471+
2472+
2473+def file_hash(path, hash_type='md5'):
2474+ """Generate a hash checksum of the contents of 'path' or None if not found.
2475+
2476+ :param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
2477+ such as md5, sha1, sha256, sha512, etc.
2478+ """
2479 if os.path.exists(path):
2480- h = hashlib.md5()
2481- with open(path, 'r') as source:
2482- h.update(source.read()) # IGNORE:E1101 - it does have update
2483+ h = getattr(hashlib, hash_type)()
2484+ with open(path, 'rb') as source:
2485+ h.update(source.read())
2486 return h.hexdigest()
2487 else:
2488 return None
2489
2490
2491-def restart_on_change(restart_map, stopstart=False):
2492+def path_hash(path):
2493+ """Generate a hash checksum of all files matching 'path'. Standard
2494+ wildcards like '*' and '?' are supported, see documentation for the 'glob'
2495+ module for more information.
2496+
2497+ :return: dict: A { filename: hash } dictionary for all matched files.
2498+ Empty if none found.
2499+ """
2500+ return {
2501+ filename: file_hash(filename)
2502+ for filename in glob.iglob(path)
2503+ }
2504+
2505+
2506+def check_hash(path, checksum, hash_type='md5'):
2507+ """Validate a file using a cryptographic checksum.
2508+
2509+ :param str checksum: Value of the checksum used to validate the file.
2510+ :param str hash_type: Hash algorithm used to generate `checksum`.
2511+ Can be any hash alrgorithm supported by :mod:`hashlib`,
2512+ such as md5, sha1, sha256, sha512, etc.
2513+ :raises ChecksumError: If the file fails the checksum
2514+
2515+ """
2516+ actual_checksum = file_hash(path, hash_type)
2517+ if checksum != actual_checksum:
2518+ raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
2519+
2520+
2521+class ChecksumError(ValueError):
2522+ """A class derived from Value error to indicate the checksum failed."""
2523+ pass
2524+
2525+
2526+def restart_on_change(restart_map, stopstart=False, restart_functions=None):
2527 """Restart services based on configuration files changing
2528
2529- This function is used a decorator, for example
2530+ This function is used a decorator, for example::
2531
2532 @restart_on_change({
2533 '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
2534+ '/etc/apache/sites-enabled/*': [ 'apache2' ]
2535 })
2536- def ceph_client_changed():
2537- ...
2538+ def config_changed():
2539+ pass # your code here
2540
2541 In this example, the cinder-api and cinder-volume services
2542 would be restarted if /etc/ceph/ceph.conf is changed by the
2543- ceph_client_changed function.
2544+ ceph_client_changed function. The apache2 service would be
2545+ restarted if any file matching the pattern got changed, created
2546+ or removed. Standard wildcards are supported, see documentation
2547+ for the 'glob' module for more information.
2548+
2549+ @param restart_map: {path_file_name: [service_name, ...]
2550+ @param stopstart: DEFAULT false; whether to stop, start OR restart
2551+ @param restart_functions: nonstandard functions to use to restart services
2552+ {svc: func, ...}
2553+ @returns result from decorated function
2554 """
2555 def wrap(f):
2556- def wrapped_f(*args):
2557- checksums = {}
2558- for path in restart_map:
2559- checksums[path] = file_hash(path)
2560- f(*args)
2561- restarts = []
2562- for path in restart_map:
2563- if checksums[path] != file_hash(path):
2564- restarts += restart_map[path]
2565- services_list = list(OrderedDict.fromkeys(restarts))
2566- if not stopstart:
2567- for service_name in services_list:
2568- service('restart', service_name)
2569- else:
2570- for action in ['stop', 'start']:
2571- for service_name in services_list:
2572- service(action, service_name)
2573+ @functools.wraps(f)
2574+ def wrapped_f(*args, **kwargs):
2575+ return restart_on_change_helper(
2576+ (lambda: f(*args, **kwargs)), restart_map, stopstart,
2577+ restart_functions)
2578 return wrapped_f
2579 return wrap
2580
2581
2582-def lsb_release():
2583- """Return /etc/lsb-release in a dict"""
2584- d = {}
2585- with open('/etc/lsb-release', 'r') as lsb:
2586- for l in lsb:
2587- k, v = l.split('=')
2588- d[k.strip()] = v.strip()
2589- return d
2590+def restart_on_change_helper(lambda_f, restart_map, stopstart=False,
2591+ restart_functions=None):
2592+ """Helper function to perform the restart_on_change function.
2593+
2594+ This is provided for decorators to restart services if files described
2595+ in the restart_map have changed after an invocation of lambda_f().
2596+
2597+ @param lambda_f: function to call.
2598+ @param restart_map: {file: [service, ...]}
2599+ @param stopstart: whether to stop, start or restart a service
2600+ @param restart_functions: nonstandard functions to use to restart services
2601+ {svc: func, ...}
2602+ @returns result of lambda_f()
2603+ """
2604+ if restart_functions is None:
2605+ restart_functions = {}
2606+ checksums = {path: path_hash(path) for path in restart_map}
2607+ r = lambda_f()
2608+ # create a list of lists of the services to restart
2609+ restarts = [restart_map[path]
2610+ for path in restart_map
2611+ if path_hash(path) != checksums[path]]
2612+ # create a flat list of ordered services without duplicates from lists
2613+ services_list = list(OrderedDict.fromkeys(itertools.chain(*restarts)))
2614+ if services_list:
2615+ actions = ('stop', 'start') if stopstart else ('restart',)
2616+ for service_name in services_list:
2617+ if service_name in restart_functions:
2618+ restart_functions[service_name](service_name)
2619+ else:
2620+ for action in actions:
2621+ service(action, service_name)
2622+ return r
2623
2624
2625 def pwgen(length=None):
2626 """Generate a random pasword."""
2627 if length is None:
2628+ # A random length is ok to use a weak PRNG
2629 length = random.choice(range(35, 45))
2630 alphanumeric_chars = [
2631- l for l in (string.letters + string.digits)
2632+ l for l in (string.ascii_letters + string.digits)
2633 if l not in 'l0QD1vAEIOUaeiou']
2634+ # Use a crypto-friendly PRNG (e.g. /dev/urandom) for making the
2635+ # actual password
2636+ random_generator = random.SystemRandom()
2637 random_chars = [
2638- random.choice(alphanumeric_chars) for _ in range(length)]
2639+ random_generator.choice(alphanumeric_chars) for _ in range(length)]
2640 return(''.join(random_chars))
2641
2642
2643-def list_nics(nic_type):
2644- '''Return a list of nics of given type(s)'''
2645- if isinstance(nic_type, basestring):
2646+def is_phy_iface(interface):
2647+ """Returns True if interface is not virtual, otherwise False."""
2648+ if interface:
2649+ sys_net = '/sys/class/net'
2650+ if os.path.isdir(sys_net):
2651+ for iface in glob.glob(os.path.join(sys_net, '*')):
2652+ if '/virtual/' in os.path.realpath(iface):
2653+ continue
2654+
2655+ if interface == os.path.basename(iface):
2656+ return True
2657+
2658+ return False
2659+
2660+
2661+def get_bond_master(interface):
2662+ """Returns bond master if interface is bond slave otherwise None.
2663+
2664+ NOTE: the provided interface is expected to be physical
2665+ """
2666+ if interface:
2667+ iface_path = '/sys/class/net/%s' % (interface)
2668+ if os.path.exists(iface_path):
2669+ if '/virtual/' in os.path.realpath(iface_path):
2670+ return None
2671+
2672+ master = os.path.join(iface_path, 'master')
2673+ if os.path.exists(master):
2674+ master = os.path.realpath(master)
2675+ # make sure it is a bond master
2676+ if os.path.exists(os.path.join(master, 'bonding')):
2677+ return os.path.basename(master)
2678+
2679+ return None
2680+
2681+
2682+def list_nics(nic_type=None):
2683+ """Return a list of nics of given type(s)"""
2684+ if isinstance(nic_type, six.string_types):
2685 int_types = [nic_type]
2686 else:
2687 int_types = nic_type
2688+
2689 interfaces = []
2690- for int_type in int_types:
2691- cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
2692- ip_output = subprocess.check_output(cmd).split('\n')
2693- ip_output = (line for line in ip_output if line)
2694+ if nic_type:
2695+ for int_type in int_types:
2696+ cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
2697+ ip_output = subprocess.check_output(cmd).decode('UTF-8')
2698+ ip_output = ip_output.split('\n')
2699+ ip_output = (line for line in ip_output if line)
2700+ for line in ip_output:
2701+ if line.split()[1].startswith(int_type):
2702+ matched = re.search('.*: (' + int_type +
2703+ r'[0-9]+\.[0-9]+)@.*', line)
2704+ if matched:
2705+ iface = matched.groups()[0]
2706+ else:
2707+ iface = line.split()[1].replace(":", "")
2708+
2709+ if iface not in interfaces:
2710+ interfaces.append(iface)
2711+ else:
2712+ cmd = ['ip', 'a']
2713+ ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
2714+ ip_output = (line.strip() for line in ip_output if line)
2715+
2716+ key = re.compile('^[0-9]+:\s+(.+):')
2717 for line in ip_output:
2718- if line.split()[1].startswith(int_type):
2719- interfaces.append(line.split()[1].replace(":", ""))
2720+ matched = re.search(key, line)
2721+ if matched:
2722+ iface = matched.group(1)
2723+ iface = iface.partition("@")[0]
2724+ if iface not in interfaces:
2725+ interfaces.append(iface)
2726+
2727 return interfaces
2728
2729
2730 def set_nic_mtu(nic, mtu):
2731- '''Set MTU on a network interface'''
2732+ """Set the Maximum Transmission Unit (MTU) on a network interface."""
2733 cmd = ['ip', 'link', 'set', nic, 'mtu', mtu]
2734 subprocess.check_call(cmd)
2735
2736
2737 def get_nic_mtu(nic):
2738+ """Return the Maximum Transmission Unit (MTU) for a network interface."""
2739 cmd = ['ip', 'addr', 'show', nic]
2740- ip_output = subprocess.check_output(cmd).split('\n')
2741+ ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
2742 mtu = ""
2743 for line in ip_output:
2744 words = line.split()
2745@@ -288,10 +813,136 @@ def get_nic_mtu(nic):
2746
2747
2748 def get_nic_hwaddr(nic):
2749+ """Return the Media Access Control (MAC) for a network interface."""
2750 cmd = ['ip', '-o', '-0', 'addr', 'show', nic]
2751- ip_output = subprocess.check_output(cmd)
2752+ ip_output = subprocess.check_output(cmd).decode('UTF-8')
2753 hwaddr = ""
2754 words = ip_output.split()
2755 if 'link/ether' in words:
2756 hwaddr = words[words.index('link/ether') + 1]
2757 return hwaddr
2758+
2759+
2760+@contextmanager
2761+def chdir(directory):
2762+ """Change the current working directory to a different directory for a code
2763+ block and return the previous directory after the block exits. Useful to
2764+ run commands from a specificed directory.
2765+
2766+ :param str directory: The directory path to change to for this context.
2767+ """
2768+ cur = os.getcwd()
2769+ try:
2770+ yield os.chdir(directory)
2771+ finally:
2772+ os.chdir(cur)
2773+
2774+
2775+def chownr(path, owner, group, follow_links=True, chowntopdir=False):
2776+ """Recursively change user and group ownership of files and directories
2777+ in given path. Doesn't chown path itself by default, only its children.
2778+
2779+ :param str path: The string path to start changing ownership.
2780+ :param str owner: The owner string to use when looking up the uid.
2781+ :param str group: The group string to use when looking up the gid.
2782+ :param bool follow_links: Also follow and chown links if True
2783+ :param bool chowntopdir: Also chown path itself if True
2784+ """
2785+ uid = pwd.getpwnam(owner).pw_uid
2786+ gid = grp.getgrnam(group).gr_gid
2787+ if follow_links:
2788+ chown = os.chown
2789+ else:
2790+ chown = os.lchown
2791+
2792+ if chowntopdir:
2793+ broken_symlink = os.path.lexists(path) and not os.path.exists(path)
2794+ if not broken_symlink:
2795+ chown(path, uid, gid)
2796+ for root, dirs, files in os.walk(path, followlinks=follow_links):
2797+ for name in dirs + files:
2798+ full = os.path.join(root, name)
2799+ broken_symlink = os.path.lexists(full) and not os.path.exists(full)
2800+ if not broken_symlink:
2801+ chown(full, uid, gid)
2802+
2803+
2804+def lchownr(path, owner, group):
2805+ """Recursively change user and group ownership of files and directories
2806+ in a given path, not following symbolic links. See the documentation for
2807+ 'os.lchown' for more information.
2808+
2809+ :param str path: The string path to start changing ownership.
2810+ :param str owner: The owner string to use when looking up the uid.
2811+ :param str group: The group string to use when looking up the gid.
2812+ """
2813+ chownr(path, owner, group, follow_links=False)
2814+
2815+
2816+def owner(path):
2817+ """Returns a tuple containing the username & groupname owning the path.
2818+
2819+ :param str path: the string path to retrieve the ownership
2820+ :return tuple(str, str): A (username, groupname) tuple containing the
2821+ name of the user and group owning the path.
2822+ :raises OSError: if the specified path does not exist
2823+ """
2824+ stat = os.stat(path)
2825+ username = pwd.getpwuid(stat.st_uid)[0]
2826+ groupname = grp.getgrgid(stat.st_gid)[0]
2827+ return username, groupname
2828+
2829+
2830+def get_total_ram():
2831+ """The total amount of system RAM in bytes.
2832+
2833+ This is what is reported by the OS, and may be overcommitted when
2834+ there are multiple containers hosted on the same machine.
2835+ """
2836+ with open('/proc/meminfo', 'r') as f:
2837+ for line in f.readlines():
2838+ if line:
2839+ key, value, unit = line.split()
2840+ if key == 'MemTotal:':
2841+ assert unit == 'kB', 'Unknown unit'
2842+ return int(value) * 1024 # Classic, not KiB.
2843+ raise NotImplementedError()
2844+
2845+
2846+UPSTART_CONTAINER_TYPE = '/run/container_type'
2847+
2848+
2849+def is_container():
2850+ """Determine whether unit is running in a container
2851+
2852+ @return: boolean indicating if unit is in a container
2853+ """
2854+ if init_is_systemd():
2855+ # Detect using systemd-detect-virt
2856+ return subprocess.call(['systemd-detect-virt',
2857+ '--container']) == 0
2858+ else:
2859+ # Detect using upstart container file marker
2860+ return os.path.exists(UPSTART_CONTAINER_TYPE)
2861+
2862+
2863+def add_to_updatedb_prunepath(path, updatedb_path=UPDATEDB_PATH):
2864+ with open(updatedb_path, 'r+') as f_id:
2865+ updatedb_text = f_id.read()
2866+ output = updatedb(updatedb_text, path)
2867+ f_id.seek(0)
2868+ f_id.write(output)
2869+ f_id.truncate()
2870+
2871+
2872+def updatedb(updatedb_text, new_path):
2873+ lines = [line for line in updatedb_text.split("\n")]
2874+ for i, line in enumerate(lines):
2875+ if line.startswith("PRUNEPATHS="):
2876+ paths_line = line.split("=")[1].replace('"', '')
2877+ paths = paths_line.split(" ")
2878+ if new_path not in paths:
2879+ paths.append(new_path)
2880+ lines[i] = 'PRUNEPATHS="{}"'.format(' '.join(paths))
2881+ output = "\n".join(lines)
2882+ return output
2883diff --git a/hooks/charmhelpers/core/host_factory/__init__.py b/hooks/charmhelpers/core/host_factory/__init__.py
2884new file mode 100644
2885index 0000000..e69de29
2886--- /dev/null
2887+++ b/hooks/charmhelpers/core/host_factory/__init__.py
2888diff --git a/hooks/charmhelpers/core/host_factory/centos.py b/hooks/charmhelpers/core/host_factory/centos.py
2889new file mode 100644
2890index 0000000..7781a39
2891--- /dev/null
2892+++ b/hooks/charmhelpers/core/host_factory/centos.py
2893@@ -0,0 +1,72 @@
2894+import subprocess
2895+import yum
2896+import os
2897+
2898+from charmhelpers.core.strutils import BasicStringComparator
2899+
2900+
2901+class CompareHostReleases(BasicStringComparator):
2902+ """Provide comparisons of Host releases.
2903+
2904+ Use in the form of
2905+
2906+ if CompareHostReleases(release) > 'trusty':
2907+ # do something with mitaka
2908+ """
2909+
2910+ def __init__(self, item):
2911+ raise NotImplementedError(
2912+ "CompareHostReleases() is not implemented for CentOS")
2913+
2914+
2915+def service_available(service_name):
2916+ # """Determine whether a system service is available."""
2917+ if os.path.isdir('/run/systemd/system'):
2918+ cmd = ['systemctl', 'is-enabled', service_name]
2919+ else:
2920+ cmd = ['service', service_name, 'is-enabled']
2921+ return subprocess.call(cmd) == 0
2922+
2923+
2924+def add_new_group(group_name, system_group=False, gid=None):
2925+ cmd = ['groupadd']
2926+ if gid:
2927+ cmd.extend(['--gid', str(gid)])
2928+ if system_group:
2929+ cmd.append('-r')
2930+ cmd.append(group_name)
2931+ subprocess.check_call(cmd)
2932+
2933+
2934+def lsb_release():
2935+ """Return /etc/os-release in a dict."""
2936+ d = {}
2937+ with open('/etc/os-release', 'r') as lsb:
2938+ for l in lsb:
2939+ s = l.split('=')
2940+ if len(s) != 2:
2941+ continue
2942+ d[s[0].strip()] = s[1].strip()
2943+ return d
2944+
2945+
2946+def cmp_pkgrevno(package, revno, pkgcache=None):
2947+ """Compare supplied revno with the revno of the installed package.
2948+
2949+ * 1 => Installed revno is greater than supplied arg
2950+ * 0 => Installed revno is the same as supplied arg
2951+ * -1 => Installed revno is less than supplied arg
2952+
2953+ This function imports YumBase function if the pkgcache argument
2954+ is None.
2955+ """
2956+ if not pkgcache:
2957+ y = yum.YumBase()
2958+ packages = y.doPackageLists()
2959+ pkgcache = {i.Name: i.version for i in packages['installed']}
2960+ pkg = pkgcache[package]
2961+ if pkg > revno:
2962+ return 1
2963+ if pkg < revno:
2964+ return -1
2965+ return 0
2966diff --git a/hooks/charmhelpers/core/host_factory/ubuntu.py b/hooks/charmhelpers/core/host_factory/ubuntu.py
2967new file mode 100644
2968index 0000000..d8dc378
2969--- /dev/null
2970+++ b/hooks/charmhelpers/core/host_factory/ubuntu.py
2971@@ -0,0 +1,89 @@
2972+import subprocess
2973+
2974+from charmhelpers.core.strutils import BasicStringComparator
2975+
2976+
2977+UBUNTU_RELEASES = (
2978+ 'lucid',
2979+ 'maverick',
2980+ 'natty',
2981+ 'oneiric',
2982+ 'precise',
2983+ 'quantal',
2984+ 'raring',
2985+ 'saucy',
2986+ 'trusty',
2987+ 'utopic',
2988+ 'vivid',
2989+ 'wily',
2990+ 'xenial',
2991+ 'yakkety',
2992+ 'zesty',
2993+ 'artful',
2994+)
2995+
2996+
2997+class CompareHostReleases(BasicStringComparator):
2998+ """Provide comparisons of Ubuntu releases.
2999+
3000+ Use in the form of
3001+
3002+ if CompareHostReleases(release) > 'trusty':
3003+ # do something with mitaka
3004+ """
3005+ _list = UBUNTU_RELEASES
3006+
3007+
3008+def service_available(service_name):
3009+ """Determine whether a system service is available"""
3010+ try:
3011+ subprocess.check_output(
3012+ ['service', service_name, 'status'],
3013+ stderr=subprocess.STDOUT).decode('UTF-8')
3014+ except subprocess.CalledProcessError as e:
3015+ return b'unrecognized service' not in e.output
3016+ else:
3017+ return True
3018+
3019+
3020+def add_new_group(group_name, system_group=False, gid=None):
3021+ cmd = ['addgroup']
3022+ if gid:
3023+ cmd.extend(['--gid', str(gid)])
3024+ if system_group:
3025+ cmd.append('--system')
3026+ else:
3027+ cmd.extend([
3028+ '--group',
3029+ ])
3030+ cmd.append(group_name)
3031+ subprocess.check_call(cmd)
3032+
3033+
3034+def lsb_release():
3035+ """Return /etc/lsb-release in a dict"""
3036+ d = {}
3037+ with open('/etc/lsb-release', 'r') as lsb:
3038+ for l in lsb:
3039+ k, v = l.split('=')
3040+ d[k.strip()] = v.strip()
3041+ return d
3042+
3043+
3044+def cmp_pkgrevno(package, revno, pkgcache=None):
3045+ """Compare supplied revno with the revno of the installed package.
3046+
3047+ * 1 => Installed revno is greater than supplied arg
3048+ * 0 => Installed revno is the same as supplied arg
3049+ * -1 => Installed revno is less than supplied arg
3050+
3051+ This function imports apt_cache function from charmhelpers.fetch if
3052+ the pkgcache argument is None. Be sure to add charmhelpers.fetch if
3053+ you call this function, or pass an apt_pkg.Cache() instance.
3054+ """
3055+ import apt_pkg
3056+ if not pkgcache:
3057+ from charmhelpers.fetch import apt_cache
3058+ pkgcache = apt_cache()
3059+ pkg = pkgcache[package]
3060+ return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
3061diff --git a/hooks/charmhelpers/core/hugepage.py b/hooks/charmhelpers/core/hugepage.py
3062new file mode 100644
3063index 0000000..54b5b5e
3064--- /dev/null
3065+++ b/hooks/charmhelpers/core/hugepage.py
3066@@ -0,0 +1,69 @@
3067+# -*- coding: utf-8 -*-
3068+
3069+# Copyright 2014-2015 Canonical Limited.
3070+#
3071+# Licensed under the Apache License, Version 2.0 (the "License");
3072+# you may not use this file except in compliance with the License.
3073+# You may obtain a copy of the License at
3074+#
3075+# http://www.apache.org/licenses/LICENSE-2.0
3076+#
3077+# Unless required by applicable law or agreed to in writing, software
3078+# distributed under the License is distributed on an "AS IS" BASIS,
3079+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3080+# See the License for the specific language governing permissions and
3081+# limitations under the License.
3082+
3083+import yaml
3084+from charmhelpers.core import fstab
3085+from charmhelpers.core import sysctl
3086+from charmhelpers.core.host import (
3087+ add_group,
3088+ add_user_to_group,
3089+ fstab_mount,
3090+ mkdir,
3091+)
3092+from charmhelpers.core.strutils import bytes_from_string
3093+from subprocess import check_output
3094+
3095+
3096+def hugepage_support(user, group='hugetlb', nr_hugepages=256,
3097+ max_map_count=65536, mnt_point='/run/hugepages/kvm',
3098+ pagesize='2MB', mount=True, set_shmmax=False):
3099+ """Enable hugepages on system.
3100+
3101+ Args:
3102+ user (str) -- Username to allow access to hugepages to
3103+ group (str) -- Group name to own hugepages
3104+ nr_hugepages (int) -- Number of pages to reserve
3105+ max_map_count (int) -- Number of Virtual Memory Areas a process can own
3106+ mnt_point (str) -- Directory to mount hugepages on
3107+ pagesize (str) -- Size of hugepages
3108+ mount (bool) -- Whether to Mount hugepages
3109+ """
3110+ group_info = add_group(group)
3111+ gid = group_info.gr_gid
3112+ add_user_to_group(user, group)
3113+ if max_map_count < 2 * nr_hugepages:
3114+ max_map_count = 2 * nr_hugepages
3115+ sysctl_settings = {
3116+ 'vm.nr_hugepages': nr_hugepages,
3117+ 'vm.max_map_count': max_map_count,
3118+ 'vm.hugetlb_shm_group': gid,
3119+ }
3120+ if set_shmmax:
3121+ shmmax_current = int(check_output(['sysctl', '-n', 'kernel.shmmax']))
3122+ shmmax_minsize = bytes_from_string(pagesize) * nr_hugepages
3123+ if shmmax_minsize > shmmax_current:
3124+ sysctl_settings['kernel.shmmax'] = shmmax_minsize
3125+ sysctl.create(yaml.dump(sysctl_settings), '/etc/sysctl.d/10-hugepage.conf')
3126+ mkdir(mnt_point, owner='root', group='root', perms=0o755, force=False)
3127+ lfstab = fstab.Fstab()
3128+ fstab_entry = lfstab.get_entry_by_attr('mountpoint', mnt_point)
3129+ if fstab_entry:
3130+ lfstab.remove_entry(fstab_entry)
3131+ entry = lfstab.Entry('nodev', mnt_point, 'hugetlbfs',
3132+ 'mode=1770,gid={},pagesize={}'.format(gid, pagesize), 0, 0)
3133+ lfstab.add_entry(entry)
3134+ if mount:
3135+ fstab_mount(mnt_point)
3136diff --git a/hooks/charmhelpers/core/kernel.py b/hooks/charmhelpers/core/kernel.py
3137new file mode 100644
3138index 0000000..2d40452
3139--- /dev/null
3140+++ b/hooks/charmhelpers/core/kernel.py
3141@@ -0,0 +1,72 @@
3142+#!/usr/bin/env python
3143+# -*- coding: utf-8 -*-
3144+
3145+# Copyright 2014-2015 Canonical Limited.
3146+#
3147+# Licensed under the Apache License, Version 2.0 (the "License");
3148+# you may not use this file except in compliance with the License.
3149+# You may obtain a copy of the License at
3150+#
3151+# http://www.apache.org/licenses/LICENSE-2.0
3152+#
3153+# Unless required by applicable law or agreed to in writing, software
3154+# distributed under the License is distributed on an "AS IS" BASIS,
3155+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3156+# See the License for the specific language governing permissions and
3157+# limitations under the License.
3158+
3159+import re
3160+import subprocess
3161+
3162+from charmhelpers.osplatform import get_platform
3163+from charmhelpers.core.hookenv import (
3164+ log,
3165+ INFO
3166+)
3167+
3168+__platform__ = get_platform()
3169+if __platform__ == "ubuntu":
3170+ from charmhelpers.core.kernel_factory.ubuntu import (
3171+ persistent_modprobe,
3172+ update_initramfs,
3173+ ) # flake8: noqa -- ignore F401 for this import
3174+elif __platform__ == "centos":
3175+ from charmhelpers.core.kernel_factory.centos import (
3176+ persistent_modprobe,
3177+ update_initramfs,
3178+ ) # flake8: noqa -- ignore F401 for this import
3179+
3180+__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
3181+
3182+
3183+def modprobe(module, persist=True):
3184+ """Load a kernel module and configure for auto-load on reboot."""
3185+ cmd = ['modprobe', module]
3186+
3187+ log('Loading kernel module %s' % module, level=INFO)
3188+
3189+ subprocess.check_call(cmd)
3190+ if persist:
3191+ persistent_modprobe(module)
3192+
3193+
3194+def rmmod(module, force=False):
3195+ """Remove a module from the linux kernel"""
3196+ cmd = ['rmmod']
3197+ if force:
3198+ cmd.append('-f')
3199+ cmd.append(module)
3200+ log('Removing kernel module %s' % module, level=INFO)
3201+ return subprocess.check_call(cmd)
3202+
3203+
3204+def lsmod():
3205+ """Shows what kernel modules are currently loaded"""
3206+ return subprocess.check_output(['lsmod'],
3207+ universal_newlines=True)
3208+
3209+
3210+def is_module_loaded(module):
3211+ """Checks if a kernel module is already loaded"""
3212+ matches = re.findall('^%s[ ]+' % module, lsmod(), re.M)
3213+ return len(matches) > 0
3214diff --git a/hooks/charmhelpers/core/kernel_factory/__init__.py b/hooks/charmhelpers/core/kernel_factory/__init__.py
3215new file mode 100644
3216index 0000000..e69de29
3217--- /dev/null
3218+++ b/hooks/charmhelpers/core/kernel_factory/__init__.py
3219diff --git a/hooks/charmhelpers/core/kernel_factory/centos.py b/hooks/charmhelpers/core/kernel_factory/centos.py
3220new file mode 100644
3221index 0000000..1c402c1
3222--- /dev/null
3223+++ b/hooks/charmhelpers/core/kernel_factory/centos.py
3224@@ -0,0 +1,17 @@
3225+import subprocess
3226+import os
3227+
3228+
3229+def persistent_modprobe(module):
3230+ """Load a kernel module and configure for auto-load on reboot."""
3231+ if not os.path.exists('/etc/rc.modules'):
3232+ open('/etc/rc.modules', 'a')
3233+ os.chmod('/etc/rc.modules', 111)
3234+ with open('/etc/rc.modules', 'r+') as modules:
3235+ if module not in modules.read():
3236+ modules.write('modprobe %s\n' % module)
3237+
3238+
3239+def update_initramfs(version='all'):
3240+ """Updates an initramfs image."""
3241+ return subprocess.check_call(["dracut", "-f", version])
3242diff --git a/hooks/charmhelpers/core/kernel_factory/ubuntu.py b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
3243new file mode 100644
3244index 0000000..3de372f
3245--- /dev/null
3246+++ b/hooks/charmhelpers/core/kernel_factory/ubuntu.py
3247@@ -0,0 +1,13 @@
3248+import subprocess
3249+
3250+
3251+def persistent_modprobe(module):
3252+ """Load a kernel module and configure for auto-load on reboot."""
3253+ with open('/etc/modules', 'r+') as modules:
3254+ if module not in modules.read():
3255+ modules.write(module + "\n")
3256+
3257+
3258+def update_initramfs(version='all'):
3259+ """Updates an initramfs image."""
3260+ return subprocess.check_call(["update-initramfs", "-k", version, "-u"])
3261diff --git a/hooks/charmhelpers/core/services/__init__.py b/hooks/charmhelpers/core/services/__init__.py
3262new file mode 100644
3263index 0000000..61fd074
3264--- /dev/null
3265+++ b/hooks/charmhelpers/core/services/__init__.py
3266@@ -0,0 +1,16 @@
3267+# Copyright 2014-2015 Canonical Limited.
3268+#
3269+# Licensed under the Apache License, Version 2.0 (the "License");
3270+# you may not use this file except in compliance with the License.
3271+# You may obtain a copy of the License at
3272+#
3273+# http://www.apache.org/licenses/LICENSE-2.0
3274+#
3275+# Unless required by applicable law or agreed to in writing, software
3276+# distributed under the License is distributed on an "AS IS" BASIS,
3277+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3278+# See the License for the specific language governing permissions and
3279+# limitations under the License.
3280+
3281+from .base import * # NOQA
3282+from .helpers import * # NOQA
3283diff --git a/hooks/charmhelpers/core/services/base.py b/hooks/charmhelpers/core/services/base.py
3284new file mode 100644
3285index 0000000..ca9dc99
3286--- /dev/null
3287+++ b/hooks/charmhelpers/core/services/base.py
3288@@ -0,0 +1,351 @@
3289+# Copyright 2014-2015 Canonical Limited.
3290+#
3291+# Licensed under the Apache License, Version 2.0 (the "License");
3292+# you may not use this file except in compliance with the License.
3293+# You may obtain a copy of the License at
3294+#
3295+# http://www.apache.org/licenses/LICENSE-2.0
3296+#
3297+# Unless required by applicable law or agreed to in writing, software
3298+# distributed under the License is distributed on an "AS IS" BASIS,
3299+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3300+# See the License for the specific language governing permissions and
3301+# limitations under the License.
3302+
3303+import os
3304+import json
3305+from inspect import getargspec
3306+from collections import Iterable, OrderedDict
3307+
3308+from charmhelpers.core import host
3309+from charmhelpers.core import hookenv
3310+
3311+
3312+__all__ = ['ServiceManager', 'ManagerCallback',
3313+ 'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
3314+ 'service_restart', 'service_stop']
3315+
3316+
3317+class ServiceManager(object):
3318+ def __init__(self, services=None):
3319+ """
3320+ Register a list of services, given their definitions.
3321+
3322+ Service definitions are dicts in the following formats (all keys except
3323+ 'service' are optional)::
3324+
3325+ {
3326+ "service": <service name>,
3327+ "required_data": <list of required data contexts>,
3328+ "provided_data": <list of provided data contexts>,
3329+ "data_ready": <one or more callbacks>,
3330+ "data_lost": <one or more callbacks>,
3331+ "start": <one or more callbacks>,
3332+ "stop": <one or more callbacks>,
3333+ "ports": <list of ports to manage>,
3334+ }
3335+
3336+ The 'required_data' list should contain dicts of required data (or
3337+ dependency managers that act like dicts and know how to collect the data).
3338+ Only when all items in the 'required_data' list are populated are the list
3339+ of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
3340+ information.
3341+
3342+ The 'provided_data' list should contain relation data providers, most likely
3343+ a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
3344+ that will indicate a set of data to set on a given relation.
3345+
3346+ The 'data_ready' value should be either a single callback, or a list of
3347+ callbacks, to be called when all items in 'required_data' pass `is_ready()`.
3348+ Each callback will be called with the service name as the only parameter.
3349+ After all of the 'data_ready' callbacks are called, the 'start' callbacks
3350+ are fired.
3351+
3352+ The 'data_lost' value should be either a single callback, or a list of
3353+ callbacks, to be called when a 'required_data' item no longer passes
3354+ `is_ready()`. Each callback will be called with the service name as the
3355+ only parameter. After all of the 'data_lost' callbacks are called,
3356+ the 'stop' callbacks are fired.
3357+
3358+ The 'start' value should be either a single callback, or a list of
3359+ callbacks, to be called when starting the service, after the 'data_ready'
3360+ callbacks are complete. Each callback will be called with the service
3361+ name as the only parameter. This defaults to
3362+ `[host.service_start, services.open_ports]`.
3363+
3364+ The 'stop' value should be either a single callback, or a list of
3365+ callbacks, to be called when stopping the service. If the service is
3366+ being stopped because it no longer has all of its 'required_data', this
3367+ will be called after all of the 'data_lost' callbacks are complete.
3368+ Each callback will be called with the service name as the only parameter.
3369+ This defaults to `[services.close_ports, host.service_stop]`.
3370+
3371+ The 'ports' value should be a list of ports to manage. The default
3372+ 'start' handler will open the ports after the service is started,
3373+ and the default 'stop' handler will close the ports prior to stopping
3374+ the service.
3375+
3376+
3377+ Examples:
3378+
3379+ The following registers an Upstart service called bingod that depends on
3380+ a mongodb relation and which runs a custom `db_migrate` function prior to
3381+ restarting the service, and a Runit service called spadesd::
3382+
3383+ manager = services.ServiceManager([
3384+ {
3385+ 'service': 'bingod',
3386+ 'ports': [80, 443],
3387+ 'required_data': [MongoRelation(), config(), {'my': 'data'}],
3388+ 'data_ready': [
3389+ services.template(source='bingod.conf'),
3390+ services.template(source='bingod.ini',
3391+ target='/etc/bingod.ini',
3392+ owner='bingo', perms=0400),
3393+ ],
3394+ },
3395+ {
3396+ 'service': 'spadesd',
3397+ 'data_ready': services.template(source='spadesd_run.j2',
3398+ target='/etc/sv/spadesd/run',
3399+ perms=0555),
3400+ 'start': runit_start,
3401+ 'stop': runit_stop,
3402+ },
3403+ ])
3404+ manager.manage()
3405+ """
3406+ self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
3407+ self._ready = None
3408+ self.services = OrderedDict()
3409+ for service in services or []:
3410+ service_name = service['service']
3411+ self.services[service_name] = service
3412+
3413+ def manage(self):
3414+ """
3415+ Handle the current hook by doing The Right Thing with the registered services.
3416+ """
3417+ hookenv._run_atstart()
3418+ try:
3419+ hook_name = hookenv.hook_name()
3420+ if hook_name == 'stop':
3421+ self.stop_services()
3422+ else:
3423+ self.reconfigure_services()
3424+ self.provide_data()
3425+ except SystemExit as x:
3426+ if x.code is None or x.code == 0:
3427+ hookenv._run_atexit()
3428+ hookenv._run_atexit()
3429+
3430+ def provide_data(self):
3431+ """
3432+ Set the relation data for each provider in the ``provided_data`` list.
3433+
3434+ A provider must have a `name` attribute, which indicates which relation
3435+ to set data on, and a `provide_data()` method, which returns a dict of
3436+ data to set.
3437+
3438+ The `provide_data()` method can optionally accept two parameters:
3439+
3440+ * ``remote_service`` The name of the remote service that the data will
3441+ be provided to. The `provide_data()` method will be called once
3442+ for each connected service (not unit). This allows the method to
3443+ tailor its data to the given service.
3444+ * ``service_ready`` Whether or not the service definition had all of
3445+ its requirements met, and thus the ``data_ready`` callbacks run.
3446+
3447+ Note that the ``provided_data`` methods are now called **after** the
3448+ ``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
3449+ a chance to generate any data necessary for the providing to the remote
3450+ services.
3451+ """
3452+ for service_name, service in self.services.items():
3453+ service_ready = self.is_ready(service_name)
3454+ for provider in service.get('provided_data', []):
3455+ for relid in hookenv.relation_ids(provider.name):
3456+ units = hookenv.related_units(relid)
3457+ if not units:
3458+ continue
3459+ remote_service = units[0].split('/')[0]
3460+ argspec = getargspec(provider.provide_data)
3461+ if len(argspec.args) > 1:
3462+ data = provider.provide_data(remote_service, service_ready)
3463+ else:
3464+ data = provider.provide_data()
3465+ if data:
3466+ hookenv.relation_set(relid, data)
3467+
3468+ def reconfigure_services(self, *service_names):
3469+ """
3470+ Update all files for one or more registered services, and,
3471+ if ready, optionally restart them.
3472+
3473+ If no service names are given, reconfigures all registered services.
3474+ """
3475+ for service_name in service_names or self.services.keys():
3476+ if self.is_ready(service_name):
3477+ self.fire_event('data_ready', service_name)
3478+ self.fire_event('start', service_name, default=[
3479+ service_restart,
3480+ manage_ports])
3481+ self.save_ready(service_name)
3482+ else:
3483+ if self.was_ready(service_name):
3484+ self.fire_event('data_lost', service_name)
3485+ self.fire_event('stop', service_name, default=[
3486+ manage_ports,
3487+ service_stop])
3488+ self.save_lost(service_name)
3489+
3490+ def stop_services(self, *service_names):
3491+ """
3492+ Stop one or more registered services, by name.
3493+
3494+ If no service names are given, stops all registered services.
3495+ """
3496+ for service_name in service_names or self.services.keys():
3497+ self.fire_event('stop', service_name, default=[
3498+ manage_ports,
3499+ service_stop])
3500+
3501+ def get_service(self, service_name):
3502+ """
3503+ Given the name of a registered service, return its service definition.
3504+ """
3505+ service = self.services.get(service_name)
3506+ if not service:
3507+ raise KeyError('Service not registered: %s' % service_name)
3508+ return service
3509+
3510+ def fire_event(self, event_name, service_name, default=None):
3511+ """
3512+ Fire a data_ready, data_lost, start, or stop event on a given service.
3513+ """
3514+ service = self.get_service(service_name)
3515+ callbacks = service.get(event_name, default)
3516+ if not callbacks:
3517+ return
3518+ if not isinstance(callbacks, Iterable):
3519+ callbacks = [callbacks]
3520+ for callback in callbacks:
3521+ if isinstance(callback, ManagerCallback):
3522+ callback(self, service_name, event_name)
3523+ else:
3524+ callback(service_name)
3525+
3526+ def is_ready(self, service_name):
3527+ """
3528+ Determine if a registered service is ready, by checking its 'required_data'.
3529+
3530+ A 'required_data' item can be any mapping type, and is considered ready
3531+ if `bool(item)` evaluates as True.
3532+ """
3533+ service = self.get_service(service_name)
3534+ reqs = service.get('required_data', [])
3535+ return all(bool(req) for req in reqs)
3536+
3537+ def _load_ready_file(self):
3538+ if self._ready is not None:
3539+ return
3540+ if os.path.exists(self._ready_file):
3541+ with open(self._ready_file) as fp:
3542+ self._ready = set(json.load(fp))
3543+ else:
3544+ self._ready = set()
3545+
3546+ def _save_ready_file(self):
3547+ if self._ready is None:
3548+ return
3549+ with open(self._ready_file, 'w') as fp:
3550+ json.dump(list(self._ready), fp)
3551+
3552+ def save_ready(self, service_name):
3553+ """
3554+ Save an indicator that the given service is now data_ready.
3555+ """
3556+ self._load_ready_file()
3557+ self._ready.add(service_name)
3558+ self._save_ready_file()
3559+
3560+ def save_lost(self, service_name):
3561+ """
3562+ Save an indicator that the given service is no longer data_ready.
3563+ """
3564+ self._load_ready_file()
3565+ self._ready.discard(service_name)
3566+ self._save_ready_file()
3567+
3568+ def was_ready(self, service_name):
3569+ """
3570+ Determine if the given service was previously data_ready.
3571+ """
3572+ self._load_ready_file()
3573+ return service_name in self._ready
3574+
3575+
3576+class ManagerCallback(object):
3577+ """
3578+ Special case of a callback that takes the `ServiceManager` instance
3579+ in addition to the service name.
3580+
3581+ Subclasses should implement `__call__` which should accept three parameters:
3582+
3583+ * `manager` The `ServiceManager` instance
3584+ * `service_name` The name of the service it's being triggered for
3585+ * `event_name` The name of the event that this callback is handling
3586+ """
3587+ def __call__(self, manager, service_name, event_name):
3588+ raise NotImplementedError()
3589+
3590+
3591+class PortManagerCallback(ManagerCallback):
3592+ """
3593+ Callback class that will open or close ports, for use as either
3594+ a start or stop action.
3595+ """
3596+ def __call__(self, manager, service_name, event_name):
3597+ service = manager.get_service(service_name)
3598+ new_ports = service.get('ports', [])
3599+ port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
3600+ if os.path.exists(port_file):
3601+ with open(port_file) as fp:
3602+ old_ports = fp.read().split(',')
3603+ for old_port in old_ports:
3604+ if bool(old_port):
3605+ old_port = int(old_port)
3606+ if old_port not in new_ports:
3607+ hookenv.close_port(old_port)
3608+ with open(port_file, 'w') as fp:
3609+ fp.write(','.join(str(port) for port in new_ports))
3610+ for port in new_ports:
3611+ if event_name == 'start':
3612+ hookenv.open_port(port)
3613+ elif event_name == 'stop':
3614+ hookenv.close_port(port)
3615+
3616+
3617+def service_stop(service_name):
3618+ """
3619+ Wrapper around host.service_stop to prevent spurious "unknown service"
3620+ messages in the logs.
3621+ """
3622+ if host.service_running(service_name):
3623+ host.service_stop(service_name)
3624+
3625+
3626+def service_restart(service_name):
3627+ """
3628+ Wrapper around host.service_restart to prevent spurious "unknown service"
3629+ messages in the logs.
3630+ """
3631+ if host.service_available(service_name):
3632+ if host.service_running(service_name):
3633+ host.service_restart(service_name)
3634+ else:
3635+ host.service_start(service_name)
3636+
3637+
3638+# Convenience aliases
3639+open_ports = close_ports = manage_ports = PortManagerCallback()
3640diff --git a/hooks/charmhelpers/core/services/helpers.py b/hooks/charmhelpers/core/services/helpers.py
3641new file mode 100644
3642index 0000000..3e6e30d
3643--- /dev/null
3644+++ b/hooks/charmhelpers/core/services/helpers.py
3645@@ -0,0 +1,290 @@
3646+# Copyright 2014-2015 Canonical Limited.
3647+#
3648+# Licensed under the Apache License, Version 2.0 (the "License");
3649+# you may not use this file except in compliance with the License.
3650+# You may obtain a copy of the License at
3651+#
3652+# http://www.apache.org/licenses/LICENSE-2.0
3653+#
3654+# Unless required by applicable law or agreed to in writing, software
3655+# distributed under the License is distributed on an "AS IS" BASIS,
3656+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3657+# See the License for the specific language governing permissions and
3658+# limitations under the License.
3659+
3660+import os
3661+import yaml
3662+
3663+from charmhelpers.core import hookenv
3664+from charmhelpers.core import host
3665+from charmhelpers.core import templating
3666+
3667+from charmhelpers.core.services.base import ManagerCallback
3668+
3669+
3670+__all__ = ['RelationContext', 'TemplateCallback',
3671+ 'render_template', 'template']
3672+
3673+
3674+class RelationContext(dict):
3675+ """
3676+ Base class for a context generator that gets relation data from juju.
3677+
3678+ Subclasses must provide the attributes `name`, which is the name of the
3679+ interface of interest, `interface`, which is the type of the interface of
3680+ interest, and `required_keys`, which is the set of keys required for the
3681+ relation to be considered complete. The data for all interfaces matching
3682+ the `name` attribute that are complete will used to populate the dictionary
3683+ values (see `get_data`, below).
3684+
3685+ The generated context will be namespaced under the relation :attr:`name`,
3686+ to prevent potential naming conflicts.
3687+
3688+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3689+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3690+ """
3691+ name = None
3692+ interface = None
3693+
3694+ def __init__(self, name=None, additional_required_keys=None):
3695+ if not hasattr(self, 'required_keys'):
3696+ self.required_keys = []
3697+
3698+ if name is not None:
3699+ self.name = name
3700+ if additional_required_keys:
3701+ self.required_keys.extend(additional_required_keys)
3702+ self.get_data()
3703+
3704+ def __bool__(self):
3705+ """
3706+ Returns True if all of the required_keys are available.
3707+ """
3708+ return self.is_ready()
3709+
3710+ __nonzero__ = __bool__
3711+
3712+ def __repr__(self):
3713+ return super(RelationContext, self).__repr__()
3714+
3715+ def is_ready(self):
3716+ """
3717+ Returns True if all of the `required_keys` are available from any units.
3718+ """
3719+ ready = len(self.get(self.name, [])) > 0
3720+ if not ready:
3721+ hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
3722+ return ready
3723+
3724+ def _is_ready(self, unit_data):
3725+ """
3726+ Helper method that tests a set of relation data and returns True if
3727+ all of the `required_keys` are present.
3728+ """
3729+ return set(unit_data.keys()).issuperset(set(self.required_keys))
3730+
3731+ def get_data(self):
3732+ """
3733+ Retrieve the relation data for each unit involved in a relation and,
3734+ if complete, store it in a list under `self[self.name]`. This
3735+ is automatically called when the RelationContext is instantiated.
3736+
3737+ The units are sorted lexographically first by the service ID, then by
3738+ the unit ID. Thus, if an interface has two other services, 'db:1'
3739+ and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
3740+ and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
3741+ set of data, the relation data for the units will be stored in the
3742+ order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
3743+
3744+ If you only care about a single unit on the relation, you can just
3745+ access it as `{{ interface[0]['key'] }}`. However, if you can at all
3746+ support multiple units on a relation, you should iterate over the list,
3747+ like::
3748+
3749+ {% for unit in interface -%}
3750+ {{ unit['key'] }}{% if not loop.last %},{% endif %}
3751+ {%- endfor %}
3752+
3753+ Note that since all sets of relation data from all related services and
3754+ units are in a single list, if you need to know which service or unit a
3755+ set of data came from, you'll need to extend this class to preserve
3756+ that information.
3757+ """
3758+ if not hookenv.relation_ids(self.name):
3759+ return
3760+
3761+ ns = self.setdefault(self.name, [])
3762+ for rid in sorted(hookenv.relation_ids(self.name)):
3763+ for unit in sorted(hookenv.related_units(rid)):
3764+ reldata = hookenv.relation_get(rid=rid, unit=unit)
3765+ if self._is_ready(reldata):
3766+ ns.append(reldata)
3767+
3768+ def provide_data(self):
3769+ """
3770+ Return data to be relation_set for this interface.
3771+ """
3772+ return {}
3773+
3774+
3775+class MysqlRelation(RelationContext):
3776+ """
3777+ Relation context for the `mysql` interface.
3778+
3779+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3780+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3781+ """
3782+ name = 'db'
3783+ interface = 'mysql'
3784+
3785+ def __init__(self, *args, **kwargs):
3786+ self.required_keys = ['host', 'user', 'password', 'database']
3787+ RelationContext.__init__(self, *args, **kwargs)
3788+
3789+
3790+class HttpRelation(RelationContext):
3791+ """
3792+ Relation context for the `http` interface.
3793+
3794+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
3795+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
3796+ """
3797+ name = 'website'
3798+ interface = 'http'
3799+
3800+ def __init__(self, *args, **kwargs):
3801+ self.required_keys = ['host', 'port']
3802+ RelationContext.__init__(self, *args, **kwargs)
3803+
3804+ def provide_data(self):
3805+ return {
3806+ 'host': hookenv.unit_get('private-address'),
3807+ 'port': 80,
3808+ }
3809+
3810+
3811+class RequiredConfig(dict):
3812+ """
3813+ Data context that loads config options with one or more mandatory options.
3814+
3815+ Once the required options have been changed from their default values, all
3816+ config options will be available, namespaced under `config` to prevent
3817+ potential naming conflicts (for example, between a config option and a
3818+ relation property).
3819+
3820+ :param list *args: List of options that must be changed from their default values.
3821+ """
3822+
3823+ def __init__(self, *args):
3824+ self.required_options = args
3825+ self['config'] = hookenv.config()
3826+ with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
3827+ self.config = yaml.load(fp).get('options', {})
3828+
3829+ def __bool__(self):
3830+ for option in self.required_options:
3831+ if option not in self['config']:
3832+ return False
3833+ current_value = self['config'][option]
3834+ default_value = self.config[option].get('default')
3835+ if current_value == default_value:
3836+ return False
3837+ if current_value in (None, '') and default_value in (None, ''):
3838+ return False
3839+ return True
3840+
3841+ def __nonzero__(self):
3842+ return self.__bool__()
3843+
3844+
3845+class StoredContext(dict):
3846+ """
3847+ A data context that always returns the data that it was first created with.
3848+
3849+ This is useful to do a one-time generation of things like passwords, that
3850+ will thereafter use the same value that was originally generated, instead
3851+ of generating a new value each time it is run.
3852+ """
3853+ def __init__(self, file_name, config_data):
3854+ """
3855+ If the file exists, populate `self` with the data from the file.
3856+ Otherwise, populate with the given data and persist it to the file.
3857+ """
3858+ if os.path.exists(file_name):
3859+ self.update(self.read_context(file_name))
3860+ else:
3861+ self.store_context(file_name, config_data)
3862+ self.update(config_data)
3863+
3864+ def store_context(self, file_name, config_data):
3865+ if not os.path.isabs(file_name):
3866+ file_name = os.path.join(hookenv.charm_dir(), file_name)
3867+ with open(file_name, 'w') as file_stream:
3868+ os.fchmod(file_stream.fileno(), 0o600)
3869+ yaml.dump(config_data, file_stream)
3870+
3871+ def read_context(self, file_name):
3872+ if not os.path.isabs(file_name):
3873+ file_name = os.path.join(hookenv.charm_dir(), file_name)
3874+ with open(file_name, 'r') as file_stream:
3875+ data = yaml.load(file_stream)
3876+ if not data:
3877+ raise OSError("%s is empty" % file_name)
3878+ return data
3879+
3880+
3881+class TemplateCallback(ManagerCallback):
3882+ """
3883+ Callback class that will render a Jinja2 template, for use as a ready
3884+ action.
3885+
3886+ :param str source: The template source file, relative to
3887+ `$CHARM_DIR/templates`
3888+
3889+ :param str target: The target to write the rendered template to (or None)
3890+ :param str owner: The owner of the rendered file
3891+ :param str group: The group of the rendered file
3892+ :param int perms: The permissions of the rendered file
3893+ :param partial on_change_action: functools partial to be executed when
3894+ rendered file changes
3895+ :param jinja2 loader template_loader: A jinja2 template loader
3896+
3897+ :return str: The rendered template
3898+ """
3899+ def __init__(self, source, target,
3900+ owner='root', group='root', perms=0o444,
3901+ on_change_action=None, template_loader=None):
3902+ self.source = source
3903+ self.target = target
3904+ self.owner = owner
3905+ self.group = group
3906+ self.perms = perms
3907+ self.on_change_action = on_change_action
3908+ self.template_loader = template_loader
3909+
3910+ def __call__(self, manager, service_name, event_name):
3911+ pre_checksum = ''
3912+ if self.on_change_action and os.path.isfile(self.target):
3913+ pre_checksum = host.file_hash(self.target)
3914+ service = manager.get_service(service_name)
3915+ context = {'ctx': {}}
3916+ for ctx in service.get('required_data', []):
3917+ context.update(ctx)
3918+ context['ctx'].update(ctx)
3919+
3920+ result = templating.render(self.source, self.target, context,
3921+ self.owner, self.group, self.perms,
3922+ template_loader=self.template_loader)
3923+ if self.on_change_action:
3924+ if pre_checksum == host.file_hash(self.target):
3925+ hookenv.log(
3926+ 'No change detected: {}'.format(self.target),
3927+ hookenv.DEBUG)
3928+ else:
3929+ self.on_change_action()
3930+
3931+ return result
3932+
3933+
3934+# Convenience aliases for templates
3935+render_template = template = TemplateCallback
3936diff --git a/hooks/charmhelpers/core/strutils.py b/hooks/charmhelpers/core/strutils.py
3937new file mode 100644
3938index 0000000..685dabd
3939--- /dev/null
3940+++ b/hooks/charmhelpers/core/strutils.py
3941@@ -0,0 +1,123 @@
3942+#!/usr/bin/env python
3943+# -*- coding: utf-8 -*-
3944+
3945+# Copyright 2014-2015 Canonical Limited.
3946+#
3947+# Licensed under the Apache License, Version 2.0 (the "License");
3948+# you may not use this file except in compliance with the License.
3949+# You may obtain a copy of the License at
3950+#
3951+# http://www.apache.org/licenses/LICENSE-2.0
3952+#
3953+# Unless required by applicable law or agreed to in writing, software
3954+# distributed under the License is distributed on an "AS IS" BASIS,
3955+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3956+# See the License for the specific language governing permissions and
3957+# limitations under the License.
3958+
3959+import six
3960+import re
3961+
3962+
3963+def bool_from_string(value):
3964+ """Interpret string value as boolean.
3965+
3966+ Returns True if value translates to True otherwise False.
3967+ """
3968+ if isinstance(value, six.string_types):
3969+ value = six.text_type(value)
3970+ else:
3971+ msg = "Unable to interpret non-string value '%s' as boolean" % (value)
3972+ raise ValueError(msg)
3973+
3974+ value = value.strip().lower()
3975+
3976+ if value in ['y', 'yes', 'true', 't', 'on']:
3977+ return True
3978+ elif value in ['n', 'no', 'false', 'f', 'off']:
3979+ return False
3980+
3981+ msg = "Unable to interpret string value '%s' as boolean" % (value)
3982+ raise ValueError(msg)
3983+
3984+
3985+def bytes_from_string(value):
3986+ """Interpret human readable string value as bytes.
3987+
3988+ Returns int
3989+ """
3990+ BYTE_POWER = {
3991+ 'K': 1,
3992+ 'KB': 1,
3993+ 'M': 2,
3994+ 'MB': 2,
3995+ 'G': 3,
3996+ 'GB': 3,
3997+ 'T': 4,
3998+ 'TB': 4,
3999+ 'P': 5,
4000+ 'PB': 5,
4001+ }
4002+ if isinstance(value, six.string_types):
4003+ value = six.text_type(value)
4004+ else:
4005+ msg = "Unable to interpret non-string value '%s' as boolean" % (value)
4006+ raise ValueError(msg)
4007+ matches = re.match("([0-9]+)([a-zA-Z]+)", value)
4008+ if not matches:
4009+ msg = "Unable to interpret string value '%s' as bytes" % (value)
4010+ raise ValueError(msg)
4011+ return int(matches.group(1)) * (1024 ** BYTE_POWER[matches.group(2)])
4012+
4013+
4014+class BasicStringComparator(object):
4015+ """Provides a class that will compare strings from an iterator type object.
4016+ Used to provide > and < comparisons on strings that may not necessarily be
4017+ alphanumerically ordered. e.g. OpenStack or Ubuntu releases AFTER the
4018+ z-wrap.
4019+ """
4020+
4021+ _list = None
4022+
4023+ def __init__(self, item):
4024+ if self._list is None:
4025+ raise Exception("Must define the _list in the class definition!")
4026+ try:
4027+ self.index = self._list.index(item)
4028+ except Exception:
4029+ raise KeyError("Item '{}' is not in list '{}'"
4030+ .format(item, self._list))
4031+
4032+ def __eq__(self, other):
4033+ assert isinstance(other, str) or isinstance(other, self.__class__)
4034+ return self.index == self._list.index(other)
4035+
4036+ def __ne__(self, other):
4037+ return not self.__eq__(other)
4038+
4039+ def __lt__(self, other):
4040+ assert isinstance(other, str) or isinstance(other, self.__class__)
4041+ return self.index < self._list.index(other)
4042+
4043+ def __ge__(self, other):
4044+ return not self.__lt__(other)
4045+
4046+ def __gt__(self, other):
4047+ assert isinstance(other, str) or isinstance(other, self.__class__)
4048+ return self.index > self._list.index(other)
4049+
4050+ def __le__(self, other):
4051+ return not self.__gt__(other)
4052+
4053+ def __str__(self):
4054+ """Always give back the item at the index so it can be used in
4055+ comparisons like:
4056+
4057+ s_mitaka = CompareOpenStack('mitaka')
4058+ s_newton = CompareOpenstack('newton')
4059+
4060+ assert s_newton > s_mitaka
4061+
4062+ @returns: <string>
4063+ """
4064+ return self._list[self.index]
4065diff --git a/hooks/charmhelpers/core/sysctl.py b/hooks/charmhelpers/core/sysctl.py
4066new file mode 100644
4067index 0000000..6e413e3
4068--- /dev/null
4069+++ b/hooks/charmhelpers/core/sysctl.py
4070@@ -0,0 +1,54 @@
4071+#!/usr/bin/env python
4072+# -*- coding: utf-8 -*-
4073+
4074+# Copyright 2014-2015 Canonical Limited.
4075+#
4076+# Licensed under the Apache License, Version 2.0 (the "License");
4077+# you may not use this file except in compliance with the License.
4078+# You may obtain a copy of the License at
4079+#
4080+# http://www.apache.org/licenses/LICENSE-2.0
4081+#
4082+# Unless required by applicable law or agreed to in writing, software
4083+# distributed under the License is distributed on an "AS IS" BASIS,
4084+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4085+# See the License for the specific language governing permissions and
4086+# limitations under the License.
4087+
4088+import yaml
4089+
4090+from subprocess import check_call
4091+
4092+from charmhelpers.core.hookenv import (
4093+ log,
4094+ DEBUG,
4095+ ERROR,
4096+)
4097+
4098+__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
4099+
4100+
4101+def create(sysctl_dict, sysctl_file):
4102+ """Creates a sysctl.conf file from a YAML associative array
4103+
4104+ :param sysctl_dict: a YAML-formatted string of sysctl options eg "{ 'kernel.max_pid': 1337 }"
4105+ :type sysctl_dict: str
4106+ :param sysctl_file: path to the sysctl file to be saved
4107+ :type sysctl_file: str or unicode
4108+ :returns: None
4109+ """
4110+ try:
4111+ sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
4112+ except yaml.YAMLError:
4113+ log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
4114+ level=ERROR)
4115+ return
4116+
4117+ with open(sysctl_file, "w") as fd:
4118+ for key, value in sysctl_dict_parsed.items():
4119+ fd.write("{}={}\n".format(key, value))
4120+
4121+ log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
4122+ level=DEBUG)
4123+
4124+ check_call(["sysctl", "-p", sysctl_file])
4125diff --git a/hooks/charmhelpers/core/templating.py b/hooks/charmhelpers/core/templating.py
4126new file mode 100644
4127index 0000000..7b801a3
4128--- /dev/null
4129+++ b/hooks/charmhelpers/core/templating.py
4130@@ -0,0 +1,84 @@
4131+# Copyright 2014-2015 Canonical Limited.
4132+#
4133+# Licensed under the Apache License, Version 2.0 (the "License");
4134+# you may not use this file except in compliance with the License.
4135+# You may obtain a copy of the License at
4136+#
4137+# http://www.apache.org/licenses/LICENSE-2.0
4138+#
4139+# Unless required by applicable law or agreed to in writing, software
4140+# distributed under the License is distributed on an "AS IS" BASIS,
4141+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4142+# See the License for the specific language governing permissions and
4143+# limitations under the License.
4144+
4145+import os
4146+import sys
4147+
4148+from charmhelpers.core import host
4149+from charmhelpers.core import hookenv
4150+
4151+
4152+def render(source, target, context, owner='root', group='root',
4153+ perms=0o444, templates_dir=None, encoding='UTF-8', template_loader=None):
4154+ """
4155+ Render a template.
4156+
4157+ The `source` path, if not absolute, is relative to the `templates_dir`.
4158+
4159+ The `target` path should be absolute. It can also be `None`, in which
4160+ case no file will be written.
4161+
4162+ The context should be a dict containing the values to be replaced in the
4163+ template.
4164+
4165+ The `owner`, `group`, and `perms` options will be passed to `write_file`.
4166+
4167+ If omitted, `templates_dir` defaults to the `templates` folder in the charm.
4168+
4169+ The rendered template will be written to the file as well as being returned
4170+ as a string.
4171+
4172+ Note: Using this requires python-jinja2 or python3-jinja2; if it is not
4173+ installed, calling this will attempt to use charmhelpers.fetch.apt_install
4174+ to install it.
4175+ """
4176+ try:
4177+ from jinja2 import FileSystemLoader, Environment, exceptions
4178+ except ImportError:
4179+ try:
4180+ from charmhelpers.fetch import apt_install
4181+ except ImportError:
4182+ hookenv.log('Could not import jinja2, and could not import '
4183+ 'charmhelpers.fetch to install it',
4184+ level=hookenv.ERROR)
4185+ raise
4186+ if sys.version_info.major == 2:
4187+ apt_install('python-jinja2', fatal=True)
4188+ else:
4189+ apt_install('python3-jinja2', fatal=True)
4190+ from jinja2 import FileSystemLoader, Environment, exceptions
4191+
4192+ if template_loader:
4193+ template_env = Environment(loader=template_loader)
4194+ else:
4195+ if templates_dir is None:
4196+ templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
4197+ template_env = Environment(loader=FileSystemLoader(templates_dir))
4198+ try:
4199+ source = source
4200+ template = template_env.get_template(source)
4201+ except exceptions.TemplateNotFound as e:
4202+ hookenv.log('Could not load template %s from %s.' %
4203+ (source, templates_dir),
4204+ level=hookenv.ERROR)
4205+ raise e
4206+ content = template.render(context)
4207+ if target is not None:
4208+ target_dir = os.path.dirname(target)
4209+ if not os.path.exists(target_dir):
4210+ # This is a terrible default directory permission, as the file
4211+ # or its siblings will often contain secrets.
4212+ host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
4213+ host.write_file(target, content.encode(encoding), owner, group, perms)
4214+ return content
4215diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
4216new file mode 100644
4217index 0000000..54ec969
4218--- /dev/null
4219+++ b/hooks/charmhelpers/core/unitdata.py
4220@@ -0,0 +1,518 @@
4221+#!/usr/bin/env python
4222+# -*- coding: utf-8 -*-
4223+#
4224+# Copyright 2014-2015 Canonical Limited.
4225+#
4226+# Licensed under the Apache License, Version 2.0 (the "License");
4227+# you may not use this file except in compliance with the License.
4228+# You may obtain a copy of the License at
4229+#
4230+# http://www.apache.org/licenses/LICENSE-2.0
4231+#
4232+# Unless required by applicable law or agreed to in writing, software
4233+# distributed under the License is distributed on an "AS IS" BASIS,
4234+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4235+# See the License for the specific language governing permissions and
4236+# limitations under the License.
4237+#
4238+# Authors:
4239+# Kapil Thangavelu <kapil.foss@gmail.com>
4240+#
4241+"""
4242+Intro
4243+-----
4244+
4245+A simple way to store state in units. This provides a key value
4246+storage with support for versioned, transactional operation,
4247+and can calculate deltas from previous values to simplify unit logic
4248+when processing changes.
4249+
4250+
4251+Hook Integration
4252+----------------
4253+
4254+There are several extant frameworks for hook execution, including
4255+
4256+ - charmhelpers.core.hookenv.Hooks
4257+ - charmhelpers.core.services.ServiceManager
4258+
4259+The storage classes are framework agnostic, one simple integration is
4260+via the HookData contextmanager. It will record the current hook
4261+execution environment (including relation data, config data, etc.),
4262+setup a transaction and allow easy access to the changes from
4263+previously seen values. One consequence of the integration is the
4264+reservation of particular keys ('rels', 'unit', 'env', 'config',
4265+'charm_revisions') for their respective values.
4266+
4267+Here's a fully worked integration example using hookenv.Hooks::
4268+
4269+ from charmhelper.core import hookenv, unitdata
4270+
4271+ hook_data = unitdata.HookData()
4272+ db = unitdata.kv()
4273+ hooks = hookenv.Hooks()
4274+
4275+ @hooks.hook
4276+ def config_changed():
4277+ # Print all changes to configuration from previously seen
4278+ # values.
4279+ for changed, (prev, cur) in hook_data.conf.items():
4280+ print('config changed', changed,
4281+ 'previous value', prev,
4282+ 'current value', cur)
4283+
4284+ # Get some unit specific bookeeping
4285+ if not db.get('pkg_key'):
4286+ key = urllib.urlopen('https://example.com/pkg_key').read()
4287+ db.set('pkg_key', key)
4288+
4289+ # Directly access all charm config as a mapping.
4290+ conf = db.getrange('config', True)
4291+
4292+ # Directly access all relation data as a mapping
4293+ rels = db.getrange('rels', True)
4294+
4295+ if __name__ == '__main__':
4296+ with hook_data():
4297+ hook.execute()
4298+
4299+
4300+A more basic integration is via the hook_scope context manager which simply
4301+manages transaction scope (and records hook name, and timestamp)::
4302+
4303+ >>> from unitdata import kv
4304+ >>> db = kv()
4305+ >>> with db.hook_scope('install'):
4306+ ... # do work, in transactional scope.
4307+ ... db.set('x', 1)
4308+ >>> db.get('x')
4309+ 1
4310+
4311+
4312+Usage
4313+-----
4314+
4315+Values are automatically json de/serialized to preserve basic typing
4316+and complex data struct capabilities (dicts, lists, ints, booleans, etc).
4317+
4318+Individual values can be manipulated via get/set::
4319+
4320+ >>> kv.set('y', True)
4321+ >>> kv.get('y')
4322+ True
4323+
4324+ # We can set complex values (dicts, lists) as a single key.
4325+ >>> kv.set('config', {'a': 1, 'b': True'})
4326+
4327+ # Also supports returning dictionaries as a record which
4328+ # provides attribute access.
4329+ >>> config = kv.get('config', record=True)
4330+ >>> config.b
4331+ True
4332+
4333+
4334+Groups of keys can be manipulated with update/getrange::
4335+
4336+ >>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
4337+ >>> kv.getrange('gui.', strip=True)
4338+ {'z': 1, 'y': 2}
4339+
4340+When updating values, its very helpful to understand which values
4341+have actually changed and how have they changed. The storage
4342+provides a delta method to provide for this::
4343+
4344+ >>> data = {'debug': True, 'option': 2}
4345+ >>> delta = kv.delta(data, 'config.')
4346+ >>> delta.debug.previous
4347+ None
4348+ >>> delta.debug.current
4349+ True
4350+ >>> delta
4351+ {'debug': (None, True), 'option': (None, 2)}
4352+
4353+Note the delta method does not persist the actual change, it needs to
4354+be explicitly saved via 'update' method::
4355+
4356+ >>> kv.update(data, 'config.')
4357+
4358+Values modified in the context of a hook scope retain historical values
4359+associated to the hookname.
4360+
4361+ >>> with db.hook_scope('config-changed'):
4362+ ... db.set('x', 42)
4363+ >>> db.gethistory('x')
4364+ [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
4365+ (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
4366+
4367+"""
4368+
4369+import collections
4370+import contextlib
4371+import datetime
4372+import itertools
4373+import json
4374+import os
4375+import pprint
4376+import sqlite3
4377+import sys
4378+
4379+__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
4380+
4381+
4382+class Storage(object):
4383+ """Simple key value database for local unit state within charms.
4384+
4385+ Modifications are not persisted unless :meth:`flush` is called.
4386+
4387+ To support dicts, lists, integer, floats, and booleans values
4388+ are automatically json encoded/decoded.
4389+ """
4390+ def __init__(self, path=None):
4391+ self.db_path = path
4392+ if path is None:
4393+ if 'UNIT_STATE_DB' in os.environ:
4394+ self.db_path = os.environ['UNIT_STATE_DB']
4395+ else:
4396+ self.db_path = os.path.join(
4397+ os.environ.get('CHARM_DIR', ''), '.unit-state.db')
4398+ self.conn = sqlite3.connect('%s' % self.db_path)
4399+ self.cursor = self.conn.cursor()
4400+ self.revision = None
4401+ self._closed = False
4402+ self._init()
4403+
4404+ def close(self):
4405+ if self._closed:
4406+ return
4407+ self.flush(False)
4408+ self.cursor.close()
4409+ self.conn.close()
4410+ self._closed = True
4411+
4412+ def get(self, key, default=None, record=False):
4413+ self.cursor.execute('select data from kv where key=?', [key])
4414+ result = self.cursor.fetchone()
4415+ if not result:
4416+ return default
4417+ if record:
4418+ return Record(json.loads(result[0]))
4419+ return json.loads(result[0])
4420+
4421+ def getrange(self, key_prefix, strip=False):
4422+ """
4423+ Get a range of keys starting with a common prefix as a mapping of
4424+ keys to values.
4425+
4426+ :param str key_prefix: Common prefix among all keys
4427+ :param bool strip: Optionally strip the common prefix from the key
4428+ names in the returned dict
4429+ :return dict: A (possibly empty) dict of key-value mappings
4430+ """
4431+ self.cursor.execute("select key, data from kv where key like ?",
4432+ ['%s%%' % key_prefix])
4433+ result = self.cursor.fetchall()
4434+
4435+ if not result:
4436+ return {}
4437+ if not strip:
4438+ key_prefix = ''
4439+ return dict([
4440+ (k[len(key_prefix):], json.loads(v)) for k, v in result])
4441+
4442+ def update(self, mapping, prefix=""):
4443+ """
4444+ Set the values of multiple keys at once.
4445+
4446+ :param dict mapping: Mapping of keys to values
4447+ :param str prefix: Optional prefix to apply to all keys in `mapping`
4448+ before setting
4449+ """
4450+ for k, v in mapping.items():
4451+ self.set("%s%s" % (prefix, k), v)
4452+
4453+ def unset(self, key):
4454+ """
4455+ Remove a key from the database entirely.
4456+ """
4457+ self.cursor.execute('delete from kv where key=?', [key])
4458+ if self.revision and self.cursor.rowcount:
4459+ self.cursor.execute(
4460+ 'insert into kv_revisions values (?, ?, ?)',
4461+ [key, self.revision, json.dumps('DELETED')])
4462+
4463+ def unsetrange(self, keys=None, prefix=""):
4464+ """
4465+ Remove a range of keys starting with a common prefix, from the database
4466+ entirely.
4467+
4468+ :param list keys: List of keys to remove.
4469+ :param str prefix: Optional prefix to apply to all keys in ``keys``
4470+ before removing.
4471+ """
4472+ if keys is not None:
4473+ keys = ['%s%s' % (prefix, key) for key in keys]
4474+ self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
4475+ if self.revision and self.cursor.rowcount:
4476+ self.cursor.execute(
4477+ 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
4478+ list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
4479+ else:
4480+ self.cursor.execute('delete from kv where key like ?',
4481+ ['%s%%' % prefix])
4482+ if self.revision and self.cursor.rowcount:
4483+ self.cursor.execute(
4484+ 'insert into kv_revisions values (?, ?, ?)',
4485+ ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
4486+
4487+ def set(self, key, value):
4488+ """
4489+ Set a value in the database.
4490+
4491+ :param str key: Key to set the value for
4492+ :param value: Any JSON-serializable value to be set
4493+ """
4494+ serialized = json.dumps(value)
4495+
4496+ self.cursor.execute('select data from kv where key=?', [key])
4497+ exists = self.cursor.fetchone()
4498+
4499+ # Skip mutations to the same value
4500+ if exists:
4501+ if exists[0] == serialized:
4502+ return value
4503+
4504+ if not exists:
4505+ self.cursor.execute(
4506+ 'insert into kv (key, data) values (?, ?)',
4507+ (key, serialized))
4508+ else:
4509+ self.cursor.execute('''
4510+ update kv
4511+ set data = ?
4512+ where key = ?''', [serialized, key])
4513+
4514+ # Save
4515+ if not self.revision:
4516+ return value
4517+
4518+ self.cursor.execute(
4519+ 'select 1 from kv_revisions where key=? and revision=?',
4520+ [key, self.revision])
4521+ exists = self.cursor.fetchone()
4522+
4523+ if not exists:
4524+ self.cursor.execute(
4525+ '''insert into kv_revisions (
4526+ revision, key, data) values (?, ?, ?)''',
4527+ (self.revision, key, serialized))
4528+ else:
4529+ self.cursor.execute(
4530+ '''
4531+ update kv_revisions
4532+ set data = ?
4533+ where key = ?
4534+ and revision = ?''',
4535+ [serialized, key, self.revision])
4536+
4537+ return value
4538+
4539+ def delta(self, mapping, prefix):
4540+ """
4541+ return a delta containing values that have changed.
4542+ """
4543+ previous = self.getrange(prefix, strip=True)
4544+ if not previous:
4545+ pk = set()
4546+ else:
4547+ pk = set(previous.keys())
4548+ ck = set(mapping.keys())
4549+ delta = DeltaSet()
4550+
4551+ # added
4552+ for k in ck.difference(pk):
4553+ delta[k] = Delta(None, mapping[k])
4554+
4555+ # removed
4556+ for k in pk.difference(ck):
4557+ delta[k] = Delta(previous[k], None)
4558+
4559+ # changed
4560+ for k in pk.intersection(ck):
4561+ c = mapping[k]
4562+ p = previous[k]
4563+ if c != p:
4564+ delta[k] = Delta(p, c)
4565+
4566+ return delta
4567+
4568+ @contextlib.contextmanager
4569+ def hook_scope(self, name=""):
4570+ """Scope all future interactions to the current hook execution
4571+ revision."""
4572+ assert not self.revision
4573+ self.cursor.execute(
4574+ 'insert into hooks (hook, date) values (?, ?)',
4575+ (name or sys.argv[0],
4576+ datetime.datetime.utcnow().isoformat()))
4577+ self.revision = self.cursor.lastrowid
4578+ try:
4579+ yield self.revision
4580+ self.revision = None
4581+ except:
4582+ self.flush(False)
4583+ self.revision = None
4584+ raise
4585+ else:
4586+ self.flush()
4587+
4588+ def flush(self, save=True):
4589+ if save:
4590+ self.conn.commit()
4591+ elif self._closed:
4592+ return
4593+ else:
4594+ self.conn.rollback()
4595+
4596+ def _init(self):
4597+ self.cursor.execute('''
4598+ create table if not exists kv (
4599+ key text,
4600+ data text,
4601+ primary key (key)
4602+ )''')
4603+ self.cursor.execute('''
4604+ create table if not exists kv_revisions (
4605+ key text,
4606+ revision integer,
4607+ data text,
4608+ primary key (key, revision)
4609+ )''')
4610+ self.cursor.execute('''
4611+ create table if not exists hooks (
4612+ version integer primary key autoincrement,
4613+ hook text,
4614+ date text
4615+ )''')
4616+ self.conn.commit()
4617+
4618+ def gethistory(self, key, deserialize=False):
4619+ self.cursor.execute(
4620+ '''
4621+ select kv.revision, kv.key, kv.data, h.hook, h.date
4622+ from kv_revisions kv,
4623+ hooks h
4624+ where kv.key=?
4625+ and kv.revision = h.version
4626+ ''', [key])
4627+ if deserialize is False:
4628+ return self.cursor.fetchall()
4629+ return map(_parse_history, self.cursor.fetchall())
4630+
4631+ def debug(self, fh=sys.stderr):
4632+ self.cursor.execute('select * from kv')
4633+ pprint.pprint(self.cursor.fetchall(), stream=fh)
4634+ self.cursor.execute('select * from kv_revisions')
4635+ pprint.pprint(self.cursor.fetchall(), stream=fh)
4636+
4637+
4638+def _parse_history(d):
4639+ return (d[0], d[1], json.loads(d[2]), d[3],
4640+ datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
4641+
4642+
4643+class HookData(object):
4644+ """Simple integration for existing hook exec frameworks.
4645+
4646+ Records all unit information, and stores deltas for processing
4647+ by the hook.
4648+
4649+ Sample::
4650+
4651+ from charmhelper.core import hookenv, unitdata
4652+
4653+ changes = unitdata.HookData()
4654+ db = unitdata.kv()
4655+ hooks = hookenv.Hooks()
4656+
4657+ @hooks.hook
4658+ def config_changed():
4659+ # View all changes to configuration
4660+ for changed, (prev, cur) in changes.conf.items():
4661+ print('config changed', changed,
4662+ 'previous value', prev,
4663+ 'current value', cur)
4664+
4665+ # Get some unit specific bookeeping
4666+ if not db.get('pkg_key'):
4667+ key = urllib.urlopen('https://example.com/pkg_key').read()
4668+ db.set('pkg_key', key)
4669+
4670+ if __name__ == '__main__':
4671+ with changes():
4672+ hook.execute()
4673+
4674+ """
4675+ def __init__(self):
4676+ self.kv = kv()
4677+ self.conf = None
4678+ self.rels = None
4679+
4680+ @contextlib.contextmanager
4681+ def __call__(self):
4682+ from charmhelpers.core import hookenv
4683+ hook_name = hookenv.hook_name()
4684+
4685+ with self.kv.hook_scope(hook_name):
4686+ self._record_charm_version(hookenv.charm_dir())
4687+ delta_config, delta_relation = self._record_hook(hookenv)
4688+ yield self.kv, delta_config, delta_relation
4689+
4690+ def _record_charm_version(self, charm_dir):
4691+ # Record revisions.. charm revisions are meaningless
4692+ # to charm authors as they don't control the revision.
4693+ # so logic dependnent on revision is not particularly
4694+ # useful, however it is useful for debugging analysis.
4695+ charm_rev = open(
4696+ os.path.join(charm_dir, 'revision')).read().strip()
4697+ charm_rev = charm_rev or '0'
4698+ revs = self.kv.get('charm_revisions', [])
4699+ if charm_rev not in revs:
4700+ revs.append(charm_rev.strip() or '0')
4701+ self.kv.set('charm_revisions', revs)
4702+
4703+ def _record_hook(self, hookenv):
4704+ data = hookenv.execution_environment()
4705+ self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
4706+ self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
4707+ self.kv.set('env', dict(data['env']))
4708+ self.kv.set('unit', data['unit'])
4709+ self.kv.set('relid', data.get('relid'))
4710+ return conf_delta, rels_delta
4711+
4712+
4713+class Record(dict):
4714+
4715+ __slots__ = ()
4716+
4717+ def __getattr__(self, k):
4718+ if k in self:
4719+ return self[k]
4720+ raise AttributeError(k)
4721+
4722+
4723+class DeltaSet(Record):
4724+
4725+ __slots__ = ()
4726+
4727+
4728+Delta = collections.namedtuple('Delta', ['previous', 'current'])
4729+
4730+
4731+_KV = None
4732+
4733+
4734+def kv():
4735+ global _KV
4736+ if _KV is None:
4737+ _KV = Storage()
4738+ return _KV
4739diff --git a/hooks/charmhelpers/fetch/__init__.py b/hooks/charmhelpers/fetch/__init__.py
4740index 07bb707..480a627 100644
4741--- a/hooks/charmhelpers/fetch/__init__.py
4742+++ b/hooks/charmhelpers/fetch/__init__.py
4743@@ -1,267 +1,191 @@
4744+# Copyright 2014-2015 Canonical Limited.
4745+#
4746+# Licensed under the Apache License, Version 2.0 (the "License");
4747+# you may not use this file except in compliance with the License.
4748+# You may obtain a copy of the License at
4749+#
4750+# http://www.apache.org/licenses/LICENSE-2.0
4751+#
4752+# Unless required by applicable law or agreed to in writing, software
4753+# distributed under the License is distributed on an "AS IS" BASIS,
4754+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4755+# See the License for the specific language governing permissions and
4756+# limitations under the License.
4757+
4758 import importlib
4759+from charmhelpers.osplatform import get_platform
4760 from yaml import safe_load
4761-from charmhelpers.core.host import (
4762- lsb_release
4763-)
4764-from urlparse import (
4765- urlparse,
4766- urlunparse,
4767-)
4768-import subprocess
4769 from charmhelpers.core.hookenv import (
4770 config,
4771 log,
4772 )
4773-import apt_pkg
4774-import os
4775-
4776-CLOUD_ARCHIVE = """# Ubuntu Cloud Archive
4777-deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
4778-"""
4779-PROPOSED_POCKET = """# Proposed
4780-deb http://archive.ubuntu.com/ubuntu {}-proposed main universe multiverse restricted
4781-"""
4782-CLOUD_ARCHIVE_POCKETS = {
4783- # Folsom
4784- 'folsom': 'precise-updates/folsom',
4785- 'precise-folsom': 'precise-updates/folsom',
4786- 'precise-folsom/updates': 'precise-updates/folsom',
4787- 'precise-updates/folsom': 'precise-updates/folsom',
4788- 'folsom/proposed': 'precise-proposed/folsom',
4789- 'precise-folsom/proposed': 'precise-proposed/folsom',
4790- 'precise-proposed/folsom': 'precise-proposed/folsom',
4791- # Grizzly
4792- 'grizzly': 'precise-updates/grizzly',
4793- 'precise-grizzly': 'precise-updates/grizzly',
4794- 'precise-grizzly/updates': 'precise-updates/grizzly',
4795- 'precise-updates/grizzly': 'precise-updates/grizzly',
4796- 'grizzly/proposed': 'precise-proposed/grizzly',
4797- 'precise-grizzly/proposed': 'precise-proposed/grizzly',
4798- 'precise-proposed/grizzly': 'precise-proposed/grizzly',
4799- # Havana
4800- 'havana': 'precise-updates/havana',
4801- 'precise-havana': 'precise-updates/havana',
4802- 'precise-havana/updates': 'precise-updates/havana',
4803- 'precise-updates/havana': 'precise-updates/havana',
4804- 'havana/proposed': 'precise-proposed/havana',
4805- 'precise-havana/proposed': 'precise-proposed/havana',
4806- 'precise-proposed/havana': 'precise-proposed/havana',
4807- # Icehouse
4808- 'icehouse': 'precise-updates/icehouse',
4809- 'precise-icehouse': 'precise-updates/icehouse',
4810- 'precise-icehouse/updates': 'precise-updates/icehouse',
4811- 'precise-updates/icehouse': 'precise-updates/icehouse',
4812- 'icehouse/proposed': 'precise-proposed/icehouse',
4813- 'precise-icehouse/proposed': 'precise-proposed/icehouse',
4814- 'precise-proposed/icehouse': 'precise-proposed/icehouse',
4815-}
4816-
4817-
4818-def filter_installed_packages(packages):
4819- """Returns a list of packages that require installation"""
4820- apt_pkg.init()
4821- cache = apt_pkg.Cache()
4822- _pkgs = []
4823- for package in packages:
4824- try:
4825- p = cache[package]
4826- p.current_ver or _pkgs.append(package)
4827- except KeyError:
4828- log('Package {} has no installation candidate.'.format(package),
4829- level='WARNING')
4830- _pkgs.append(package)
4831- return _pkgs
4832
4833+import six
4834+if six.PY3:
4835+ from urllib.parse import urlparse, urlunparse
4836+else:
4837+ from urlparse import urlparse, urlunparse
4838
4839-def apt_install(packages, options=None, fatal=False):
4840- """Install one or more packages"""
4841- if options is None:
4842- options = ['--option=Dpkg::Options::=--force-confold']
4843
4844- cmd = ['apt-get', '--assume-yes']
4845- cmd.extend(options)
4846- cmd.append('install')
4847- if isinstance(packages, basestring):
4848- cmd.append(packages)
4849- else:
4850- cmd.extend(packages)
4851- log("Installing {} with options: {}".format(packages,
4852- options))
4853- env = os.environ.copy()
4854- if 'DEBIAN_FRONTEND' not in env:
4855- env['DEBIAN_FRONTEND'] = 'noninteractive'
4856-
4857- if fatal:
4858- subprocess.check_call(cmd, env=env)
4859- else:
4860- subprocess.call(cmd, env=env)
4861+# The order of this list is very important. Handlers should be listed in from
4862+# least- to most-specific URL matching.
4863+FETCH_HANDLERS = (
4864+ 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
4865+ 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
4866+ 'charmhelpers.fetch.giturl.GitUrlFetchHandler',
4867+)
4868
4869
4870-def apt_update(fatal=False):
4871- """Update local apt cache"""
4872- cmd = ['apt-get', 'update']
4873- if fatal:
4874- subprocess.check_call(cmd)
4875- else:
4876- subprocess.call(cmd)
4877+class SourceConfigError(Exception):
4878+ pass
4879
4880
4881-def apt_purge(packages, fatal=False):
4882- """Purge one or more packages"""
4883- cmd = ['apt-get', '--assume-yes', 'purge']
4884- if isinstance(packages, basestring):
4885- cmd.append(packages)
4886- else:
4887- cmd.extend(packages)
4888- log("Purging {}".format(packages))
4889- if fatal:
4890- subprocess.check_call(cmd)
4891- else:
4892- subprocess.call(cmd)
4893+class UnhandledSource(Exception):
4894+ pass
4895
4896
4897-def apt_hold(packages, fatal=False):
4898- """Hold one or more packages"""
4899- cmd = ['apt-mark', 'hold']
4900- if isinstance(packages, basestring):
4901- cmd.append(packages)
4902- else:
4903- cmd.extend(packages)
4904- log("Holding {}".format(packages))
4905- if fatal:
4906- subprocess.check_call(cmd)
4907- else:
4908- subprocess.call(cmd)
4909-
4910-
4911-def add_source(source, key=None):
4912- if (source.startswith('ppa:') or
4913- source.startswith('http') or
4914- source.startswith('deb ') or
4915- source.startswith('cloud-archive:')):
4916- subprocess.check_call(['add-apt-repository', '--yes', source])
4917- elif source.startswith('cloud:'):
4918- apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),
4919- fatal=True)
4920- pocket = source.split(':')[-1]
4921- if pocket not in CLOUD_ARCHIVE_POCKETS:
4922- raise SourceConfigError(
4923- 'Unsupported cloud: source option %s' %
4924- pocket)
4925- actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket]
4926- with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt:
4927- apt.write(CLOUD_ARCHIVE.format(actual_pocket))
4928- elif source == 'proposed':
4929- release = lsb_release()['DISTRIB_CODENAME']
4930- with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
4931- apt.write(PROPOSED_POCKET.format(release))
4932- if key:
4933- subprocess.check_call(['apt-key', 'adv', '--keyserver',
4934- 'keyserver.ubuntu.com', '--recv',
4935- key])
4936+class AptLockError(Exception):
4937+ pass
4938
4939
4940-class SourceConfigError(Exception):
4941+class GPGKeyError(Exception):
4942+ """Exception occurs when a GPG key cannot be fetched or used. The message
4943+ indicates what the problem is.
4944+ """
4945 pass
4946
4947
4948+class BaseFetchHandler(object):
4949+
4950+ """Base class for FetchHandler implementations in fetch plugins"""
4951+
4952+ def can_handle(self, source):
4953+ """Returns True if the source can be handled. Otherwise returns
4954+ a string explaining why it cannot"""
4955+ return "Wrong source type"
4956+
4957+ def install(self, source):
4958+ """Try to download and unpack the source. Return the path to the
4959+ unpacked files or raise UnhandledSource."""
4960+ raise UnhandledSource("Wrong source type {}".format(source))
4961+
4962+ def parse_url(self, url):
4963+ return urlparse(url)
4964+
4965+ def base_url(self, url):
4966+ """Return url without querystring or fragment"""
4967+ parts = list(self.parse_url(url))
4968+ parts[4:] = ['' for i in parts[4:]]
4969+ return urlunparse(parts)
4970+
4971+
4972+__platform__ = get_platform()
4973+module = "charmhelpers.fetch.%s" % __platform__
4974+fetch = importlib.import_module(module)
4975+
4976+filter_installed_packages = fetch.filter_installed_packages
4977+install = fetch.apt_install
4978+upgrade = fetch.apt_upgrade
4979+update = _fetch_update = fetch.apt_update
4980+purge = fetch.apt_purge
4981+add_source = fetch.add_source
4982+
4983+if __platform__ == "ubuntu":
4984+ apt_cache = fetch.apt_cache
4985+ apt_install = fetch.apt_install
4986+ apt_update = fetch.apt_update
4987+ apt_upgrade = fetch.apt_upgrade
4988+ apt_purge = fetch.apt_purge
4989+ apt_mark = fetch.apt_mark
4990+ apt_hold = fetch.apt_hold
4991+ apt_unhold = fetch.apt_unhold
4992+ import_key = fetch.import_key
4993+ get_upstream_version = fetch.get_upstream_version
4994+elif __platform__ == "centos":
4995+ yum_search = fetch.yum_search
4996+
4997+
4998 def configure_sources(update=False,
4999 sources_var='install_sources',
5000 keys_var='install_keys'):
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches