Merge ~jacekn/squid-reverseproxy-charm:master into squid-reverseproxy-charm:master

Proposed by Jacek Nykis
Status: Merged
Approved by: David Lawson
Approved revision: fef2287b380a554e7dabad17283cf6bbf6f9ac9d
Merged at revision: f6087f897ba0f06a25b80391a92994c28db36457
Proposed branch: ~jacekn/squid-reverseproxy-charm:master
Merge into: squid-reverseproxy-charm:master
Diff against target: 7572 lines (+6069/-434)
43 files modified
charm-helpers.yaml (+2/-1)
config.yaml (+1/-1)
hooks/charmhelpers/__init__.py (+97/-0)
hooks/charmhelpers/contrib/__init__.py (+13/-0)
hooks/charmhelpers/contrib/charmsupport/__init__.py (+13/-0)
hooks/charmhelpers/contrib/charmsupport/nrpe.py (+253/-21)
hooks/charmhelpers/contrib/charmsupport/volumes.py (+19/-2)
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 (+1045/-62)
hooks/charmhelpers/core/host.py (+888/-85)
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 (+90/-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 (+362/-0)
hooks/charmhelpers/core/services/helpers.py (+290/-0)
hooks/charmhelpers/core/strutils.py (+129/-0)
hooks/charmhelpers/core/sysctl.py (+58/-0)
hooks/charmhelpers/core/templating.py (+93/-0)
hooks/charmhelpers/core/unitdata.py (+525/-0)
hooks/charmhelpers/fetch/__init__.py (+145/-149)
hooks/charmhelpers/fetch/archiveurl.py (+126/-9)
hooks/charmhelpers/fetch/bzrurl.py (+53/-21)
hooks/charmhelpers/fetch/centos.py (+171/-0)
hooks/charmhelpers/fetch/giturl.py (+69/-0)
hooks/charmhelpers/fetch/snap.py (+150/-0)
hooks/charmhelpers/fetch/ubuntu.py (+592/-0)
hooks/charmhelpers/osplatform.py (+25/-0)
hooks/hooks.py (+41/-20)
hooks/install (+3/-0)
hooks/tests/test_helpers.py (+32/-21)
hooks/tests/test_nrpe_hooks.py (+20/-39)
metadata.yaml (+1/-0)
scripts/charm_helpers_sync.py (+258/-0)
templates/main_config.template (+3/-3)
Reviewer Review Type Date Requested Status
Squid Reverse Proxy Charmers Pending
Review via email: mp+353237@code.launchpad.net

Commit message

Update charm to support bionic

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision f6087f897ba0f06a25b80391a92994c28db36457

Preview Diff

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

Subscribers

People subscribed via source and target branches

to all changes: