Merge lp:~woutervb/charms/trusty/conn-check/update_charmhelpers into lp:~ubuntuone-hackers/charms/trusty/conn-check/trunk

Proposed by Wouter van Bommel
Status: Rejected
Rejected by: Wouter van Bommel
Proposed branch: lp:~woutervb/charms/trusty/conn-check/update_charmhelpers
Merge into: lp:~ubuntuone-hackers/charms/trusty/conn-check/trunk
Diff against target: 33431 lines (+31025/-441)
173 files modified
hooks/charmhelpers/__init__.py (+67/-4)
hooks/charmhelpers/cli/README.rst (+57/-0)
hooks/charmhelpers/cli/__init__.py (+196/-0)
hooks/charmhelpers/cli/benchmark.py (+34/-0)
hooks/charmhelpers/cli/commands.py (+30/-0)
hooks/charmhelpers/cli/hookenv.py (+21/-0)
hooks/charmhelpers/cli/host.py (+29/-0)
hooks/charmhelpers/cli/unitdata.py (+46/-0)
hooks/charmhelpers/context.py (+205/-0)
hooks/charmhelpers/contrib/amulet/__init__.py (+13/-0)
hooks/charmhelpers/contrib/amulet/deployment.py (+99/-0)
hooks/charmhelpers/contrib/amulet/utils.py (+820/-0)
hooks/charmhelpers/contrib/ansible/__init__.py (+142/-88)
hooks/charmhelpers/contrib/benchmark/__init__.py (+124/-0)
hooks/charmhelpers/contrib/charmhelpers/IMPORT (+4/-0)
hooks/charmhelpers/contrib/charmhelpers/__init__.py (+203/-0)
hooks/charmhelpers/contrib/charmsupport/IMPORT (+14/-0)
hooks/charmhelpers/contrib/charmsupport/nrpe.py (+131/-28)
hooks/charmhelpers/contrib/charmsupport/volumes.py (+173/-0)
hooks/charmhelpers/contrib/database/__init__.py (+11/-0)
hooks/charmhelpers/contrib/database/mysql.py (+840/-0)
hooks/charmhelpers/contrib/hahelpers/__init__.py (+13/-0)
hooks/charmhelpers/contrib/hahelpers/apache.py (+90/-0)
hooks/charmhelpers/contrib/hahelpers/cluster.py (+451/-0)
hooks/charmhelpers/contrib/hardening/README.hardening.md (+38/-0)
hooks/charmhelpers/contrib/hardening/__init__.py (+13/-0)
hooks/charmhelpers/contrib/hardening/apache/__init__.py (+17/-0)
hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py (+29/-0)
hooks/charmhelpers/contrib/hardening/apache/checks/config.py (+104/-0)
hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf (+32/-0)
hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf (+31/-0)
hooks/charmhelpers/contrib/hardening/audits/__init__.py (+54/-0)
hooks/charmhelpers/contrib/hardening/audits/apache.py (+105/-0)
hooks/charmhelpers/contrib/hardening/audits/apt.py (+104/-0)
hooks/charmhelpers/contrib/hardening/audits/file.py (+550/-0)
hooks/charmhelpers/contrib/hardening/defaults/apache.yaml (+16/-0)
hooks/charmhelpers/contrib/hardening/defaults/apache.yaml.schema (+12/-0)
hooks/charmhelpers/contrib/hardening/defaults/mysql.yaml (+38/-0)
hooks/charmhelpers/contrib/hardening/defaults/mysql.yaml.schema (+15/-0)
hooks/charmhelpers/contrib/hardening/defaults/os.yaml (+68/-0)
hooks/charmhelpers/contrib/hardening/defaults/os.yaml.schema (+43/-0)
hooks/charmhelpers/contrib/hardening/defaults/ssh.yaml (+49/-0)
hooks/charmhelpers/contrib/hardening/defaults/ssh.yaml.schema (+42/-0)
hooks/charmhelpers/contrib/hardening/harden.py (+96/-0)
hooks/charmhelpers/contrib/hardening/host/__init__.py (+17/-0)
hooks/charmhelpers/contrib/hardening/host/checks/__init__.py (+48/-0)
hooks/charmhelpers/contrib/hardening/host/checks/apt.py (+37/-0)
hooks/charmhelpers/contrib/hardening/host/checks/limits.py (+53/-0)
hooks/charmhelpers/contrib/hardening/host/checks/login.py (+65/-0)
hooks/charmhelpers/contrib/hardening/host/checks/minimize_access.py (+50/-0)
hooks/charmhelpers/contrib/hardening/host/checks/pam.py (+132/-0)
hooks/charmhelpers/contrib/hardening/host/checks/profile.py (+49/-0)
hooks/charmhelpers/contrib/hardening/host/checks/securetty.py (+37/-0)
hooks/charmhelpers/contrib/hardening/host/checks/suid_sgid.py (+129/-0)
hooks/charmhelpers/contrib/hardening/host/checks/sysctl.py (+209/-0)
hooks/charmhelpers/contrib/hardening/host/templates/10.hardcore.conf (+8/-0)
hooks/charmhelpers/contrib/hardening/host/templates/99-hardening.sh (+5/-0)
hooks/charmhelpers/contrib/hardening/host/templates/99-juju-hardening.conf (+7/-0)
hooks/charmhelpers/contrib/hardening/host/templates/login.defs (+349/-0)
hooks/charmhelpers/contrib/hardening/host/templates/modules (+117/-0)
hooks/charmhelpers/contrib/hardening/host/templates/passwdqc.conf (+11/-0)
hooks/charmhelpers/contrib/hardening/host/templates/pinerolo_profile.sh (+8/-0)
hooks/charmhelpers/contrib/hardening/host/templates/securetty (+11/-0)
hooks/charmhelpers/contrib/hardening/host/templates/tally2 (+14/-0)
hooks/charmhelpers/contrib/hardening/mysql/__init__.py (+17/-0)
hooks/charmhelpers/contrib/hardening/mysql/checks/__init__.py (+29/-0)
hooks/charmhelpers/contrib/hardening/mysql/checks/config.py (+87/-0)
hooks/charmhelpers/contrib/hardening/mysql/templates/hardening.cnf (+12/-0)
hooks/charmhelpers/contrib/hardening/ssh/__init__.py (+17/-0)
hooks/charmhelpers/contrib/hardening/ssh/checks/__init__.py (+29/-0)
hooks/charmhelpers/contrib/hardening/ssh/checks/config.py (+435/-0)
hooks/charmhelpers/contrib/hardening/ssh/templates/ssh_config (+70/-0)
hooks/charmhelpers/contrib/hardening/ssh/templates/sshd_config (+159/-0)
hooks/charmhelpers/contrib/hardening/templating.py (+73/-0)
hooks/charmhelpers/contrib/hardening/utils.py (+155/-0)
hooks/charmhelpers/contrib/mellanox/__init__.py (+13/-0)
hooks/charmhelpers/contrib/mellanox/infiniband.py (+153/-0)
hooks/charmhelpers/contrib/network/__init__.py (+13/-0)
hooks/charmhelpers/contrib/network/ip.py (+603/-0)
hooks/charmhelpers/contrib/network/ovs/__init__.py (+693/-0)
hooks/charmhelpers/contrib/network/ovs/ovn.py (+233/-0)
hooks/charmhelpers/contrib/network/ovs/ovsdb.py (+246/-0)
hooks/charmhelpers/contrib/network/ovs/utils.py (+26/-0)
hooks/charmhelpers/contrib/network/ufw.py (+386/-0)
hooks/charmhelpers/contrib/openstack/__init__.py (+13/-0)
hooks/charmhelpers/contrib/openstack/alternatives.py (+44/-0)
hooks/charmhelpers/contrib/openstack/amulet/__init__.py (+13/-0)
hooks/charmhelpers/contrib/openstack/amulet/deployment.py (+387/-0)
hooks/charmhelpers/contrib/openstack/amulet/utils.py (+1595/-0)
hooks/charmhelpers/contrib/openstack/audits/__init__.py (+212/-0)
hooks/charmhelpers/contrib/openstack/audits/openstack_security_guide.py (+270/-0)
hooks/charmhelpers/contrib/openstack/cert_utils.py (+443/-0)
hooks/charmhelpers/contrib/openstack/context.py (+3313/-0)
hooks/charmhelpers/contrib/openstack/deferred_events.py (+416/-0)
hooks/charmhelpers/contrib/openstack/exceptions.py (+26/-0)
hooks/charmhelpers/contrib/openstack/files/__init__.py (+16/-0)
hooks/charmhelpers/contrib/openstack/files/check_haproxy.sh (+34/-0)
hooks/charmhelpers/contrib/openstack/files/check_haproxy_queue_depth.sh (+30/-0)
hooks/charmhelpers/contrib/openstack/files/policy_rc_d_script.py (+196/-0)
hooks/charmhelpers/contrib/openstack/ha/__init__.py (+13/-0)
hooks/charmhelpers/contrib/openstack/ha/utils.py (+348/-0)
hooks/charmhelpers/contrib/openstack/ip.py (+235/-0)
hooks/charmhelpers/contrib/openstack/keystone.py (+178/-0)
hooks/charmhelpers/contrib/openstack/neutron.py (+359/-0)
hooks/charmhelpers/contrib/openstack/policy_rcd.py (+173/-0)
hooks/charmhelpers/contrib/openstack/policyd.py (+801/-0)
hooks/charmhelpers/contrib/openstack/ssh_migrations.py (+412/-0)
hooks/charmhelpers/contrib/openstack/templates/__init__.py (+16/-0)
hooks/charmhelpers/contrib/openstack/templates/ceph.conf (+28/-0)
hooks/charmhelpers/contrib/openstack/templates/git.upstart (+17/-0)
hooks/charmhelpers/contrib/openstack/templates/haproxy.cfg (+77/-0)
hooks/charmhelpers/contrib/openstack/templates/logrotate (+9/-0)
hooks/charmhelpers/contrib/openstack/templates/memcached.conf (+53/-0)
hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend (+35/-0)
hooks/charmhelpers/contrib/openstack/templates/section-ceph-bluestore-compression (+28/-0)
hooks/charmhelpers/contrib/openstack/templates/section-keystone-authtoken (+12/-0)
hooks/charmhelpers/contrib/openstack/templates/section-keystone-authtoken-legacy (+10/-0)
hooks/charmhelpers/contrib/openstack/templates/section-keystone-authtoken-mitaka (+22/-0)
hooks/charmhelpers/contrib/openstack/templates/section-keystone-authtoken-v3only (+9/-0)
hooks/charmhelpers/contrib/openstack/templates/section-oslo-cache (+6/-0)
hooks/charmhelpers/contrib/openstack/templates/section-oslo-messaging-rabbit (+10/-0)
hooks/charmhelpers/contrib/openstack/templates/section-oslo-messaging-rabbit-ocata (+10/-0)
hooks/charmhelpers/contrib/openstack/templates/section-oslo-middleware (+5/-0)
hooks/charmhelpers/contrib/openstack/templates/section-oslo-notifications (+15/-0)
hooks/charmhelpers/contrib/openstack/templates/section-placement (+20/-0)
hooks/charmhelpers/contrib/openstack/templates/section-rabbitmq-oslo (+22/-0)
hooks/charmhelpers/contrib/openstack/templates/section-zeromq (+14/-0)
hooks/charmhelpers/contrib/openstack/templates/vendor_data.json (+1/-0)
hooks/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf (+91/-0)
hooks/charmhelpers/contrib/openstack/templating.py (+379/-0)
hooks/charmhelpers/contrib/openstack/utils.py (+2684/-0)
hooks/charmhelpers/contrib/openstack/vaultlocker.py (+179/-0)
hooks/charmhelpers/contrib/peerstorage/__init__.py (+267/-0)
hooks/charmhelpers/contrib/python.py (+21/-0)
hooks/charmhelpers/contrib/saltstack/__init__.py (+116/-0)
hooks/charmhelpers/contrib/ssl/__init__.py (+92/-0)
hooks/charmhelpers/contrib/ssl/service.py (+277/-0)
hooks/charmhelpers/contrib/storage/__init__.py (+13/-0)
hooks/charmhelpers/contrib/storage/linux/__init__.py (+13/-0)
hooks/charmhelpers/contrib/storage/linux/bcache.py (+74/-0)
hooks/charmhelpers/contrib/storage/linux/ceph.py (+2381/-0)
hooks/charmhelpers/contrib/storage/linux/loopback.py (+92/-0)
hooks/charmhelpers/contrib/storage/linux/lvm.py (+182/-0)
hooks/charmhelpers/contrib/storage/linux/utils.py (+128/-0)
hooks/charmhelpers/contrib/templating/jinja.py (+51/-0)
hooks/charmhelpers/contrib/templating/pyformat.py (+27/-0)
hooks/charmhelpers/contrib/unison/__init__.py (+316/-0)
hooks/charmhelpers/coordinator.py (+606/-0)
hooks/charmhelpers/core/decorators.py (+38/-0)
hooks/charmhelpers/core/hookenv.py (+651/-48)
hooks/charmhelpers/core/host.py (+622/-96)
hooks/charmhelpers/core/host_factory/centos.py (+16/-0)
hooks/charmhelpers/core/host_factory/ubuntu.py (+69/-5)
hooks/charmhelpers/core/kernel.py (+2/-2)
hooks/charmhelpers/core/services/base.py (+25/-9)
hooks/charmhelpers/core/strutils.py (+64/-5)
hooks/charmhelpers/core/sysctl.py (+32/-11)
hooks/charmhelpers/core/templating.py (+18/-9)
hooks/charmhelpers/core/unitdata.py (+8/-1)
hooks/charmhelpers/fetch/__init__.py (+22/-9)
hooks/charmhelpers/fetch/archiveurl.py (+1/-1)
hooks/charmhelpers/fetch/bzrurl.py (+2/-2)
hooks/charmhelpers/fetch/centos.py (+1/-1)
hooks/charmhelpers/fetch/giturl.py (+2/-2)
hooks/charmhelpers/fetch/python/__init__.py (+13/-0)
hooks/charmhelpers/fetch/python/debug.py (+54/-0)
hooks/charmhelpers/fetch/python/packages.py (+154/-0)
hooks/charmhelpers/fetch/python/rpdb.py (+56/-0)
hooks/charmhelpers/fetch/python/version.py (+32/-0)
hooks/charmhelpers/fetch/snap.py (+150/-0)
hooks/charmhelpers/fetch/ubuntu.py (+643/-118)
hooks/charmhelpers/fetch/ubuntu_apt_pkg.py (+312/-0)
hooks/charmhelpers/osplatform.py (+29/-2)
To merge this branch: bzr merge lp:~woutervb/charms/trusty/conn-check/update_charmhelpers
Reviewer Review Type Date Requested Status
Ubuntu One hackers Pending
Review via email: mp+402614@code.launchpad.net
To post a comment you must log in.

Unmerged revisions

58. By Wouter van Bommel

Updated charmhelpers to revision 4b8c496 from repo https://github.com/juju/charm-helpers.git

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'hooks/charmhelpers/__init__.py'
2--- hooks/charmhelpers/__init__.py 2016-12-20 14:35:00 +0000
3+++ hooks/charmhelpers/__init__.py 2021-05-12 04:07:51 +0000
4@@ -14,23 +14,86 @@
5
6 # Bootstrap charm-helpers, installing its dependencies if necessary using
7 # only standard libraries.
8+from __future__ import print_function
9+from __future__ import absolute_import
10+
11+import functools
12+import inspect
13 import subprocess
14 import sys
15
16 try:
17- import six # flake8: noqa
18+ import six # NOQA:F401
19 except ImportError:
20 if sys.version_info.major == 2:
21 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
22 else:
23 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
24- import six # flake8: noqa
25+ import six # NOQA:F401
26
27 try:
28- import yaml # flake8: noqa
29+ import yaml # NOQA:F401
30 except ImportError:
31 if sys.version_info.major == 2:
32 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
33 else:
34 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
35- import yaml # flake8: noqa
36+ import yaml # NOQA:F401
37+
38+
39+# Holds a list of mapping of mangled function names that have been deprecated
40+# using the @deprecate decorator below. This is so that the warning is only
41+# printed once for each usage of the function.
42+__deprecated_functions = {}
43+
44+
45+def deprecate(warning, date=None, log=None):
46+ """Add a deprecation warning the first time the function is used.
47+
48+ The date which is a string in semi-ISO8660 format indicates the year-month
49+ that the function is officially going to be removed.
50+
51+ usage:
52+
53+ @deprecate('use core/fetch/add_source() instead', '2017-04')
54+ def contributed_add_source_thing(...):
55+ ...
56+
57+ And it then prints to the log ONCE that the function is deprecated.
58+ The reason for passing the logging function (log) is so that hookenv.log
59+ can be used for a charm if needed.
60+
61+ :param warning: String to indicate what is to be used instead.
62+ :param date: Optional string in YYYY-MM format to indicate when the
63+ function will definitely (probably) be removed.
64+ :param log: The log function to call in order to log. If None, logs to
65+ stdout
66+ """
67+ def wrap(f):
68+
69+ @functools.wraps(f)
70+ def wrapped_f(*args, **kwargs):
71+ try:
72+ module = inspect.getmodule(f)
73+ file = inspect.getsourcefile(f)
74+ lines = inspect.getsourcelines(f)
75+ f_name = "{}-{}-{}..{}-{}".format(
76+ module.__name__, file, lines[0], lines[-1], f.__name__)
77+ except (IOError, TypeError):
78+ # assume it was local, so just use the name of the function
79+ f_name = f.__name__
80+ if f_name not in __deprecated_functions:
81+ __deprecated_functions[f_name] = True
82+ s = "DEPRECATION WARNING: Function {} is being removed".format(
83+ f.__name__)
84+ if date:
85+ s = "{} on/around {}".format(s, date)
86+ if warning:
87+ s = "{} : {}".format(s, warning)
88+ if log:
89+ log(s)
90+ else:
91+ print(s)
92+ return f(*args, **kwargs)
93+ return wrapped_f
94+ return wrap
95
96=== added directory 'hooks/charmhelpers/cli'
97=== added file 'hooks/charmhelpers/cli/README.rst'
98--- hooks/charmhelpers/cli/README.rst 1970-01-01 00:00:00 +0000
99+++ hooks/charmhelpers/cli/README.rst 2021-05-12 04:07:51 +0000
100@@ -0,0 +1,57 @@
101+==========
102+Commandant
103+==========
104+
105+-----------------------------------------------------
106+Automatic command-line interfaces to Python functions
107+-----------------------------------------------------
108+
109+One of the benefits of ``libvirt`` is the uniformity of the interface: the C API (as well as the bindings in other languages) is a set of functions that accept parameters that are nearly identical to the command-line arguments. If you run ``virsh``, you get an interactive command prompt that supports all of the same commands that your shell scripts use as ``virsh`` subcommands.
110+
111+Command execution and stdio manipulation is the greatest common factor across all development systems in the POSIX environment. By exposing your functions as commands that manipulate streams of text, you can make life easier for all the Ruby and Erlang and Go programmers in your life.
112+
113+Goals
114+=====
115+
116+* Single decorator to expose a function as a command.
117+ * now two decorators - one "automatic" and one that allows authors to manipulate the arguments for fine-grained control.(MW)
118+* Automatic analysis of function signature through ``inspect.getargspec()`` on python 2 or ``inspect.getfullargspec()`` on python 3
119+* Command argument parser built automatically with ``argparse``
120+* Interactive interpreter loop object made with ``Cmd``
121+* Options to output structured return value data via ``pprint``, ``yaml`` or ``json`` dumps.
122+
123+Other Important Features that need writing
124+------------------------------------------
125+
126+* Help and Usage documentation can be automatically generated, but it will be important to let users override this behaviour
127+* The decorator should allow specifying further parameters to the parser's add_argument() calls, to specify types or to make arguments behave as boolean flags, etc.
128+ - Filename arguments are important, as good practice is for functions to accept file objects as parameters.
129+ - choices arguments help to limit bad input before the function is called
130+* Some automatic behaviour could make for better defaults, once the user can override them.
131+ - We could automatically detect arguments that default to False or True, and automatically support --no-foo for foo=True.
132+ - We could automatically support hyphens as alternates for underscores
133+ - Arguments defaulting to sequence types could support the ``append`` action.
134+
135+
136+-----------------------------------------------------
137+Implementing subcommands
138+-----------------------------------------------------
139+
140+(WIP)
141+
142+So as to avoid dependencies on the cli module, subcommands should be defined separately from their implementations. The recommmendation would be to place definitions into separate modules near the implementations which they expose.
143+
144+Some examples::
145+
146+ from charmhelpers.cli import CommandLine
147+ from charmhelpers.payload import execd
148+ from charmhelpers.foo import bar
149+
150+ cli = CommandLine()
151+
152+ cli.subcommand(execd.execd_run)
153+
154+ @cli.subcommand_builder("bar", help="Bar baz qux")
155+ def barcmd_builder(subparser):
156+ subparser.add_argument('argument1', help="yackety")
157+ return bar
158
159=== added file 'hooks/charmhelpers/cli/__init__.py'
160--- hooks/charmhelpers/cli/__init__.py 1970-01-01 00:00:00 +0000
161+++ hooks/charmhelpers/cli/__init__.py 2021-05-12 04:07:51 +0000
162@@ -0,0 +1,196 @@
163+# Copyright 2014-2015 Canonical Limited.
164+#
165+# Licensed under the Apache License, Version 2.0 (the "License");
166+# you may not use this file except in compliance with the License.
167+# You may obtain a copy of the License at
168+#
169+# http://www.apache.org/licenses/LICENSE-2.0
170+#
171+# Unless required by applicable law or agreed to in writing, software
172+# distributed under the License is distributed on an "AS IS" BASIS,
173+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
174+# See the License for the specific language governing permissions and
175+# limitations under the License.
176+
177+import inspect
178+import argparse
179+import sys
180+
181+import six
182+from six.moves import zip
183+
184+import charmhelpers.core.unitdata
185+
186+
187+class OutputFormatter(object):
188+ def __init__(self, outfile=sys.stdout):
189+ self.formats = (
190+ "raw",
191+ "json",
192+ "py",
193+ "yaml",
194+ "csv",
195+ "tab",
196+ )
197+ self.outfile = outfile
198+
199+ def add_arguments(self, argument_parser):
200+ formatgroup = argument_parser.add_mutually_exclusive_group()
201+ choices = self.supported_formats
202+ formatgroup.add_argument("--format", metavar='FMT',
203+ help="Select output format for returned data, "
204+ "where FMT is one of: {}".format(choices),
205+ choices=choices, default='raw')
206+ for fmt in self.formats:
207+ fmtfunc = getattr(self, fmt)
208+ formatgroup.add_argument("-{}".format(fmt[0]),
209+ "--{}".format(fmt), action='store_const',
210+ const=fmt, dest='format',
211+ help=fmtfunc.__doc__)
212+
213+ @property
214+ def supported_formats(self):
215+ return self.formats
216+
217+ def raw(self, output):
218+ """Output data as raw string (default)"""
219+ if isinstance(output, (list, tuple)):
220+ output = '\n'.join(map(str, output))
221+ self.outfile.write(str(output))
222+
223+ def py(self, output):
224+ """Output data as a nicely-formatted python data structure"""
225+ import pprint
226+ pprint.pprint(output, stream=self.outfile)
227+
228+ def json(self, output):
229+ """Output data in JSON format"""
230+ import json
231+ json.dump(output, self.outfile)
232+
233+ def yaml(self, output):
234+ """Output data in YAML format"""
235+ import yaml
236+ yaml.safe_dump(output, self.outfile)
237+
238+ def csv(self, output):
239+ """Output data as excel-compatible CSV"""
240+ import csv
241+ csvwriter = csv.writer(self.outfile)
242+ csvwriter.writerows(output)
243+
244+ def tab(self, output):
245+ """Output data in excel-compatible tab-delimited format"""
246+ import csv
247+ csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
248+ csvwriter.writerows(output)
249+
250+ def format_output(self, output, fmt='raw'):
251+ fmtfunc = getattr(self, fmt)
252+ fmtfunc(output)
253+
254+
255+class CommandLine(object):
256+ argument_parser = None
257+ subparsers = None
258+ formatter = None
259+ exit_code = 0
260+
261+ def __init__(self):
262+ if not self.argument_parser:
263+ self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
264+ if not self.formatter:
265+ self.formatter = OutputFormatter()
266+ self.formatter.add_arguments(self.argument_parser)
267+ if not self.subparsers:
268+ self.subparsers = self.argument_parser.add_subparsers(help='Commands')
269+
270+ def subcommand(self, command_name=None):
271+ """
272+ Decorate a function as a subcommand. Use its arguments as the
273+ command-line arguments"""
274+ def wrapper(decorated):
275+ cmd_name = command_name or decorated.__name__
276+ subparser = self.subparsers.add_parser(cmd_name,
277+ description=decorated.__doc__)
278+ for args, kwargs in describe_arguments(decorated):
279+ subparser.add_argument(*args, **kwargs)
280+ subparser.set_defaults(func=decorated)
281+ return decorated
282+ return wrapper
283+
284+ def test_command(self, decorated):
285+ """
286+ Subcommand is a boolean test function, so bool return values should be
287+ converted to a 0/1 exit code.
288+ """
289+ decorated._cli_test_command = True
290+ return decorated
291+
292+ def no_output(self, decorated):
293+ """
294+ Subcommand is not expected to return a value, so don't print a spurious None.
295+ """
296+ decorated._cli_no_output = True
297+ return decorated
298+
299+ def subcommand_builder(self, command_name, description=None):
300+ """
301+ Decorate a function that builds a subcommand. Builders should accept a
302+ single argument (the subparser instance) and return the function to be
303+ run as the command."""
304+ def wrapper(decorated):
305+ subparser = self.subparsers.add_parser(command_name)
306+ func = decorated(subparser)
307+ subparser.set_defaults(func=func)
308+ subparser.description = description or func.__doc__
309+ return wrapper
310+
311+ def run(self):
312+ "Run cli, processing arguments and executing subcommands."
313+ arguments = self.argument_parser.parse_args()
314+ if six.PY2:
315+ argspec = inspect.getargspec(arguments.func)
316+ else:
317+ argspec = inspect.getfullargspec(arguments.func)
318+ vargs = []
319+ for arg in argspec.args:
320+ vargs.append(getattr(arguments, arg))
321+ if argspec.varargs:
322+ vargs.extend(getattr(arguments, argspec.varargs))
323+ output = arguments.func(*vargs)
324+ if getattr(arguments.func, '_cli_test_command', False):
325+ self.exit_code = 0 if output else 1
326+ output = ''
327+ if getattr(arguments.func, '_cli_no_output', False):
328+ output = ''
329+ self.formatter.format_output(output, arguments.format)
330+ if charmhelpers.core.unitdata._KV:
331+ charmhelpers.core.unitdata._KV.flush()
332+
333+
334+cmdline = CommandLine()
335+
336+
337+def describe_arguments(func):
338+ """
339+ Analyze a function's signature and return a data structure suitable for
340+ passing in as arguments to an argparse parser's add_argument() method."""
341+
342+ if six.PY2:
343+ argspec = inspect.getargspec(func)
344+ else:
345+ argspec = inspect.getfullargspec(func)
346+ # we should probably raise an exception somewhere if func includes **kwargs
347+ if argspec.defaults:
348+ positional_args = argspec.args[:-len(argspec.defaults)]
349+ keyword_names = argspec.args[-len(argspec.defaults):]
350+ for arg, default in zip(keyword_names, argspec.defaults):
351+ yield ('--{}'.format(arg),), {'default': default}
352+ else:
353+ positional_args = argspec.args
354+
355+ for arg in positional_args:
356+ yield (arg,), {}
357+ if argspec.varargs:
358+ yield (argspec.varargs,), {'nargs': '*'}
359
360=== added file 'hooks/charmhelpers/cli/benchmark.py'
361--- hooks/charmhelpers/cli/benchmark.py 1970-01-01 00:00:00 +0000
362+++ hooks/charmhelpers/cli/benchmark.py 2021-05-12 04:07:51 +0000
363@@ -0,0 +1,34 @@
364+# Copyright 2014-2015 Canonical Limited.
365+#
366+# Licensed under the Apache License, Version 2.0 (the "License");
367+# you may not use this file except in compliance with the License.
368+# You may obtain a copy of the License at
369+#
370+# http://www.apache.org/licenses/LICENSE-2.0
371+#
372+# Unless required by applicable law or agreed to in writing, software
373+# distributed under the License is distributed on an "AS IS" BASIS,
374+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
375+# See the License for the specific language governing permissions and
376+# limitations under the License.
377+
378+from . import cmdline
379+from charmhelpers.contrib.benchmark import Benchmark
380+
381+
382+@cmdline.subcommand(command_name='benchmark-start')
383+def start():
384+ Benchmark.start()
385+
386+
387+@cmdline.subcommand(command_name='benchmark-finish')
388+def finish():
389+ Benchmark.finish()
390+
391+
392+@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
393+def service(subparser):
394+ subparser.add_argument("value", help="The composite score.")
395+ subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
396+ subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
397+ return Benchmark.set_composite_score
398
399=== added file 'hooks/charmhelpers/cli/commands.py'
400--- hooks/charmhelpers/cli/commands.py 1970-01-01 00:00:00 +0000
401+++ hooks/charmhelpers/cli/commands.py 2021-05-12 04:07:51 +0000
402@@ -0,0 +1,30 @@
403+# Copyright 2014-2015 Canonical Limited.
404+#
405+# Licensed under the Apache License, Version 2.0 (the "License");
406+# you may not use this file except in compliance with the License.
407+# You may obtain a copy of the License at
408+#
409+# http://www.apache.org/licenses/LICENSE-2.0
410+#
411+# Unless required by applicable law or agreed to in writing, software
412+# distributed under the License is distributed on an "AS IS" BASIS,
413+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
414+# See the License for the specific language governing permissions and
415+# limitations under the License.
416+
417+"""
418+This module loads sub-modules into the python runtime so they can be
419+discovered via the inspect module. In order to prevent flake8 from (rightfully)
420+telling us these are unused modules, throw a ' # noqa' at the end of each import
421+so that the warning is suppressed.
422+"""
423+
424+from . import CommandLine # noqa
425+
426+"""
427+Import the sub-modules which have decorated subcommands to register with chlp.
428+"""
429+from . import host # noqa
430+from . import benchmark # noqa
431+from . import unitdata # noqa
432+from . import hookenv # noqa
433
434=== added file 'hooks/charmhelpers/cli/hookenv.py'
435--- hooks/charmhelpers/cli/hookenv.py 1970-01-01 00:00:00 +0000
436+++ hooks/charmhelpers/cli/hookenv.py 2021-05-12 04:07:51 +0000
437@@ -0,0 +1,21 @@
438+# Copyright 2014-2015 Canonical Limited.
439+#
440+# Licensed under the Apache License, Version 2.0 (the "License");
441+# you may not use this file except in compliance with the License.
442+# You may obtain a copy of the License at
443+#
444+# http://www.apache.org/licenses/LICENSE-2.0
445+#
446+# Unless required by applicable law or agreed to in writing, software
447+# distributed under the License is distributed on an "AS IS" BASIS,
448+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
449+# See the License for the specific language governing permissions and
450+# limitations under the License.
451+
452+from . import cmdline
453+from charmhelpers.core import hookenv
454+
455+
456+cmdline.subcommand('relation-id')(hookenv.relation_id._wrapped)
457+cmdline.subcommand('service-name')(hookenv.service_name)
458+cmdline.subcommand('remote-service-name')(hookenv.remote_service_name._wrapped)
459
460=== added file 'hooks/charmhelpers/cli/host.py'
461--- hooks/charmhelpers/cli/host.py 1970-01-01 00:00:00 +0000
462+++ hooks/charmhelpers/cli/host.py 2021-05-12 04:07:51 +0000
463@@ -0,0 +1,29 @@
464+# Copyright 2014-2015 Canonical Limited.
465+#
466+# Licensed under the Apache License, Version 2.0 (the "License");
467+# you may not use this file except in compliance with the License.
468+# You may obtain a copy of the License at
469+#
470+# http://www.apache.org/licenses/LICENSE-2.0
471+#
472+# Unless required by applicable law or agreed to in writing, software
473+# distributed under the License is distributed on an "AS IS" BASIS,
474+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
475+# See the License for the specific language governing permissions and
476+# limitations under the License.
477+
478+from . import cmdline
479+from charmhelpers.core import host
480+
481+
482+@cmdline.subcommand()
483+def mounts():
484+ "List mounts"
485+ return host.mounts()
486+
487+
488+@cmdline.subcommand_builder('service', description="Control system services")
489+def service(subparser):
490+ subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
491+ subparser.add_argument("service_name", help="Name of the service to control")
492+ return host.service
493
494=== added file 'hooks/charmhelpers/cli/unitdata.py'
495--- hooks/charmhelpers/cli/unitdata.py 1970-01-01 00:00:00 +0000
496+++ hooks/charmhelpers/cli/unitdata.py 2021-05-12 04:07:51 +0000
497@@ -0,0 +1,46 @@
498+# Copyright 2014-2015 Canonical Limited.
499+#
500+# Licensed under the Apache License, Version 2.0 (the "License");
501+# you may not use this file except in compliance with the License.
502+# You may obtain a copy of the License at
503+#
504+# http://www.apache.org/licenses/LICENSE-2.0
505+#
506+# Unless required by applicable law or agreed to in writing, software
507+# distributed under the License is distributed on an "AS IS" BASIS,
508+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
509+# See the License for the specific language governing permissions and
510+# limitations under the License.
511+
512+from . import cmdline
513+from charmhelpers.core import unitdata
514+
515+
516+@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
517+def unitdata_cmd(subparser):
518+ nested = subparser.add_subparsers()
519+
520+ get_cmd = nested.add_parser('get', help='Retrieve data')
521+ get_cmd.add_argument('key', help='Key to retrieve the value of')
522+ get_cmd.set_defaults(action='get', value=None)
523+
524+ getrange_cmd = nested.add_parser('getrange', help='Retrieve multiple data')
525+ getrange_cmd.add_argument('key', metavar='prefix',
526+ help='Prefix of the keys to retrieve')
527+ getrange_cmd.set_defaults(action='getrange', value=None)
528+
529+ set_cmd = nested.add_parser('set', help='Store data')
530+ set_cmd.add_argument('key', help='Key to set')
531+ set_cmd.add_argument('value', help='Value to store')
532+ set_cmd.set_defaults(action='set')
533+
534+ def _unitdata_cmd(action, key, value):
535+ if action == 'get':
536+ return unitdata.kv().get(key)
537+ elif action == 'getrange':
538+ return unitdata.kv().getrange(key)
539+ elif action == 'set':
540+ unitdata.kv().set(key, value)
541+ unitdata.kv().flush()
542+ return ''
543+ return _unitdata_cmd
544
545=== added file 'hooks/charmhelpers/context.py'
546--- hooks/charmhelpers/context.py 1970-01-01 00:00:00 +0000
547+++ hooks/charmhelpers/context.py 2021-05-12 04:07:51 +0000
548@@ -0,0 +1,205 @@
549+# Copyright 2015 Canonical Limited.
550+#
551+# Licensed under the Apache License, Version 2.0 (the "License");
552+# you may not use this file except in compliance with the License.
553+# You may obtain a copy of the License at
554+#
555+# http://www.apache.org/licenses/LICENSE-2.0
556+#
557+# Unless required by applicable law or agreed to in writing, software
558+# distributed under the License is distributed on an "AS IS" BASIS,
559+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
560+# See the License for the specific language governing permissions and
561+# limitations under the License.
562+
563+'''
564+A Pythonic API to interact with the charm hook environment.
565+
566+:author: Stuart Bishop <stuart.bishop@canonical.com>
567+'''
568+
569+import six
570+
571+from charmhelpers.core import hookenv
572+
573+from collections import OrderedDict
574+if six.PY3:
575+ from collections import UserDict # pragma: nocover
576+else:
577+ from UserDict import IterableUserDict as UserDict # pragma: nocover
578+
579+
580+class Relations(OrderedDict):
581+ '''Mapping relation name -> relation id -> Relation.
582+
583+ >>> rels = Relations()
584+ >>> rels['sprog']['sprog:12']['client/6']['widget']
585+ 'remote widget'
586+ >>> rels['sprog']['sprog:12'].local['widget'] = 'local widget'
587+ >>> rels['sprog']['sprog:12'].local['widget']
588+ 'local widget'
589+ >>> rels.peer.local['widget']
590+ 'local widget on the peer relation'
591+ '''
592+ def __init__(self):
593+ super(Relations, self).__init__()
594+ for relname in sorted(hookenv.relation_types()):
595+ self[relname] = OrderedDict()
596+ relids = hookenv.relation_ids(relname)
597+ relids.sort(key=lambda x: int(x.split(':', 1)[-1]))
598+ for relid in relids:
599+ self[relname][relid] = Relation(relid)
600+
601+ @property
602+ def peer(self):
603+ peer_relid = hookenv.peer_relation_id()
604+ for rels in self.values():
605+ if peer_relid in rels:
606+ return rels[peer_relid]
607+
608+
609+class Relation(OrderedDict):
610+ '''Mapping of unit -> remote RelationInfo for a relation.
611+
612+ This is an OrderedDict mapping, ordered numerically by
613+ by unit number.
614+
615+ Also provides access to the local RelationInfo, and peer RelationInfo
616+ instances by the 'local' and 'peers' attributes.
617+
618+ >>> r = Relation('sprog:12')
619+ >>> r.keys()
620+ ['client/9', 'client/10'] # Ordered numerically
621+ >>> r['client/10']['widget'] # A remote RelationInfo setting
622+ 'remote widget'
623+ >>> r.local['widget'] # The local RelationInfo setting
624+ 'local widget'
625+ '''
626+ relid = None # The relation id.
627+ relname = None # The relation name (also known as relation type).
628+ service = None # The remote service name, if known.
629+ local = None # The local end's RelationInfo.
630+ peers = None # Map of peer -> RelationInfo. None if no peer relation.
631+
632+ def __init__(self, relid):
633+ remote_units = hookenv.related_units(relid)
634+ remote_units.sort(key=lambda u: int(u.split('/', 1)[-1]))
635+ super(Relation, self).__init__((unit, RelationInfo(relid, unit))
636+ for unit in remote_units)
637+
638+ self.relname = relid.split(':', 1)[0]
639+ self.relid = relid
640+ self.local = RelationInfo(relid, hookenv.local_unit())
641+
642+ for relinfo in self.values():
643+ self.service = relinfo.service
644+ break
645+
646+ # If we have peers, and they have joined both the provided peer
647+ # relation and this relation, we can peek at their data too.
648+ # This is useful for creating consensus without leadership.
649+ peer_relid = hookenv.peer_relation_id()
650+ if peer_relid and peer_relid != relid:
651+ peers = hookenv.related_units(peer_relid)
652+ if peers:
653+ peers.sort(key=lambda u: int(u.split('/', 1)[-1]))
654+ self.peers = OrderedDict((peer, RelationInfo(relid, peer))
655+ for peer in peers)
656+ else:
657+ self.peers = OrderedDict()
658+ else:
659+ self.peers = None
660+
661+ def __str__(self):
662+ return '{} ({})'.format(self.relid, self.service)
663+
664+
665+class RelationInfo(UserDict):
666+ '''The bag of data at an end of a relation.
667+
668+ Every unit participating in a relation has a single bag of
669+ data associated with that relation. This is that bag.
670+
671+ The bag of data for the local unit may be updated. Remote data
672+ is immutable and will remain static for the duration of the hook.
673+
674+ Changes made to the local units relation data only become visible
675+ to other units after the hook completes successfully. If the hook
676+ does not complete successfully, the changes are rolled back.
677+
678+ Unlike standard Python mappings, setting an item to None is the
679+ same as deleting it.
680+
681+ >>> relinfo = RelationInfo('db:12') # Default is the local unit.
682+ >>> relinfo['user'] = 'fred'
683+ >>> relinfo['user']
684+ 'fred'
685+ >>> relinfo['user'] = None
686+ >>> 'fred' in relinfo
687+ False
688+
689+ This class wraps hookenv.relation_get and hookenv.relation_set.
690+ All caching is left up to these two methods to avoid synchronization
691+ issues. Data is only loaded on demand.
692+ '''
693+ relid = None # The relation id.
694+ relname = None # The relation name (also know as the relation type).
695+ unit = None # The unit id.
696+ number = None # The unit number (integer).
697+ service = None # The service name.
698+
699+ def __init__(self, relid, unit):
700+ self.relname = relid.split(':', 1)[0]
701+ self.relid = relid
702+ self.unit = unit
703+ self.service, num = self.unit.split('/', 1)
704+ self.number = int(num)
705+
706+ def __str__(self):
707+ return '{} ({})'.format(self.relid, self.unit)
708+
709+ @property
710+ def data(self):
711+ return hookenv.relation_get(rid=self.relid, unit=self.unit)
712+
713+ def __setitem__(self, key, value):
714+ if self.unit != hookenv.local_unit():
715+ raise TypeError('Attempting to set {} on remote unit {}'
716+ ''.format(key, self.unit))
717+ if value is not None and not isinstance(value, six.string_types):
718+ # We don't do implicit casting. This would cause simple
719+ # types like integers to be read back as strings in subsequent
720+ # hooks, and mutable types would require a lot of wrapping
721+ # to ensure relation-set gets called when they are mutated.
722+ raise ValueError('Only string values allowed')
723+ hookenv.relation_set(self.relid, {key: value})
724+
725+ def __delitem__(self, key):
726+ # Deleting a key and setting it to null is the same thing in
727+ # Juju relations.
728+ self[key] = None
729+
730+
731+class Leader(UserDict):
732+ def __init__(self):
733+ pass # Don't call superclass initializer, as it will nuke self.data
734+
735+ @property
736+ def data(self):
737+ return hookenv.leader_get()
738+
739+ def __setitem__(self, key, value):
740+ if not hookenv.is_leader():
741+ raise TypeError('Not the leader. Cannot change leader settings.')
742+ if value is not None and not isinstance(value, six.string_types):
743+ # We don't do implicit casting. This would cause simple
744+ # types like integers to be read back as strings in subsequent
745+ # hooks, and mutable types would require a lot of wrapping
746+ # to ensure leader-set gets called when they are mutated.
747+ raise ValueError('Only string values allowed')
748+ hookenv.leader_set({key: value})
749+
750+ def __delitem__(self, key):
751+ # Deleting a key and setting it to null is the same thing in
752+ # Juju leadership settings.
753+ self[key] = None
754
755=== added directory 'hooks/charmhelpers/contrib/amulet'
756=== added file 'hooks/charmhelpers/contrib/amulet/__init__.py'
757--- hooks/charmhelpers/contrib/amulet/__init__.py 1970-01-01 00:00:00 +0000
758+++ hooks/charmhelpers/contrib/amulet/__init__.py 2021-05-12 04:07:51 +0000
759@@ -0,0 +1,13 @@
760+# Copyright 2014-2015 Canonical Limited.
761+#
762+# Licensed under the Apache License, Version 2.0 (the "License");
763+# you may not use this file except in compliance with the License.
764+# You may obtain a copy of the License at
765+#
766+# http://www.apache.org/licenses/LICENSE-2.0
767+#
768+# Unless required by applicable law or agreed to in writing, software
769+# distributed under the License is distributed on an "AS IS" BASIS,
770+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
771+# See the License for the specific language governing permissions and
772+# limitations under the License.
773
774=== added file 'hooks/charmhelpers/contrib/amulet/deployment.py'
775--- hooks/charmhelpers/contrib/amulet/deployment.py 1970-01-01 00:00:00 +0000
776+++ hooks/charmhelpers/contrib/amulet/deployment.py 2021-05-12 04:07:51 +0000
777@@ -0,0 +1,99 @@
778+# Copyright 2014-2015 Canonical Limited.
779+#
780+# Licensed under the Apache License, Version 2.0 (the "License");
781+# you may not use this file except in compliance with the License.
782+# You may obtain a copy of the License at
783+#
784+# http://www.apache.org/licenses/LICENSE-2.0
785+#
786+# Unless required by applicable law or agreed to in writing, software
787+# distributed under the License is distributed on an "AS IS" BASIS,
788+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
789+# See the License for the specific language governing permissions and
790+# limitations under the License.
791+
792+import amulet
793+import os
794+import six
795+
796+
797+class AmuletDeployment(object):
798+ """Amulet deployment.
799+
800+ This class provides generic Amulet deployment and test runner
801+ methods.
802+ """
803+
804+ def __init__(self, series=None):
805+ """Initialize the deployment environment."""
806+ self.series = None
807+
808+ if series:
809+ self.series = series
810+ self.d = amulet.Deployment(series=self.series)
811+ else:
812+ self.d = amulet.Deployment()
813+
814+ def _add_services(self, this_service, other_services):
815+ """Add services.
816+
817+ Add services to the deployment where this_service is the local charm
818+ that we're testing and other_services are the other services that
819+ are being used in the local amulet tests.
820+ """
821+ if this_service['name'] != os.path.basename(os.getcwd()):
822+ s = this_service['name']
823+ msg = "The charm's root directory name needs to be {}".format(s)
824+ amulet.raise_status(amulet.FAIL, msg=msg)
825+
826+ if 'units' not in this_service:
827+ this_service['units'] = 1
828+
829+ self.d.add(this_service['name'], units=this_service['units'],
830+ constraints=this_service.get('constraints'),
831+ storage=this_service.get('storage'))
832+
833+ for svc in other_services:
834+ if 'location' in svc:
835+ branch_location = svc['location']
836+ elif self.series:
837+ branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
838+ else:
839+ branch_location = None
840+
841+ if 'units' not in svc:
842+ svc['units'] = 1
843+
844+ self.d.add(svc['name'], charm=branch_location, units=svc['units'],
845+ constraints=svc.get('constraints'),
846+ storage=svc.get('storage'))
847+
848+ def _add_relations(self, relations):
849+ """Add all of the relations for the services."""
850+ for k, v in six.iteritems(relations):
851+ self.d.relate(k, v)
852+
853+ def _configure_services(self, configs):
854+ """Configure all of the services."""
855+ for service, config in six.iteritems(configs):
856+ self.d.configure(service, config)
857+
858+ def _deploy(self):
859+ """Deploy environment and wait for all hooks to finish executing."""
860+ timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 900))
861+ try:
862+ self.d.setup(timeout=timeout)
863+ self.d.sentry.wait(timeout=timeout)
864+ except amulet.helpers.TimeoutError:
865+ amulet.raise_status(
866+ amulet.FAIL,
867+ msg="Deployment timed out ({}s)".format(timeout)
868+ )
869+ except Exception:
870+ raise
871+
872+ def run_tests(self):
873+ """Run all of the methods that are prefixed with 'test_'."""
874+ for test in dir(self):
875+ if test.startswith('test_'):
876+ getattr(self, test)()
877
878=== added file 'hooks/charmhelpers/contrib/amulet/utils.py'
879--- hooks/charmhelpers/contrib/amulet/utils.py 1970-01-01 00:00:00 +0000
880+++ hooks/charmhelpers/contrib/amulet/utils.py 2021-05-12 04:07:51 +0000
881@@ -0,0 +1,820 @@
882+# Copyright 2014-2015 Canonical Limited.
883+#
884+# Licensed under the Apache License, Version 2.0 (the "License");
885+# you may not use this file except in compliance with the License.
886+# You may obtain a copy of the License at
887+#
888+# http://www.apache.org/licenses/LICENSE-2.0
889+#
890+# Unless required by applicable law or agreed to in writing, software
891+# distributed under the License is distributed on an "AS IS" BASIS,
892+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
893+# See the License for the specific language governing permissions and
894+# limitations under the License.
895+
896+import io
897+import json
898+import logging
899+import os
900+import re
901+import socket
902+import subprocess
903+import sys
904+import time
905+import uuid
906+
907+import amulet
908+import distro_info
909+import six
910+from six.moves import configparser
911+if six.PY3:
912+ from urllib import parse as urlparse
913+else:
914+ import urlparse
915+
916+
917+class AmuletUtils(object):
918+ """Amulet utilities.
919+
920+ This class provides common utility functions that are used by Amulet
921+ tests.
922+ """
923+
924+ def __init__(self, log_level=logging.ERROR):
925+ self.log = self.get_logger(level=log_level)
926+ self.ubuntu_releases = self.get_ubuntu_releases()
927+
928+ def get_logger(self, name="amulet-logger", level=logging.DEBUG):
929+ """Get a logger object that will log to stdout."""
930+ log = logging
931+ logger = log.getLogger(name)
932+ fmt = log.Formatter("%(asctime)s %(funcName)s "
933+ "%(levelname)s: %(message)s")
934+
935+ handler = log.StreamHandler(stream=sys.stdout)
936+ handler.setLevel(level)
937+ handler.setFormatter(fmt)
938+
939+ logger.addHandler(handler)
940+ logger.setLevel(level)
941+
942+ return logger
943+
944+ def valid_ip(self, ip):
945+ if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
946+ return True
947+ else:
948+ return False
949+
950+ def valid_url(self, url):
951+ p = re.compile(
952+ r'^(?:http|ftp)s?://'
953+ r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
954+ r'localhost|'
955+ r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
956+ r'(?::\d+)?'
957+ r'(?:/?|[/?]\S+)$',
958+ re.IGNORECASE)
959+ if p.match(url):
960+ return True
961+ else:
962+ return False
963+
964+ def get_ubuntu_release_from_sentry(self, sentry_unit):
965+ """Get Ubuntu release codename from sentry unit.
966+
967+ :param sentry_unit: amulet sentry/service unit pointer
968+ :returns: list of strings - release codename, failure message
969+ """
970+ msg = None
971+ cmd = 'lsb_release -cs'
972+ release, code = sentry_unit.ssh(cmd)
973+ if code == 0:
974+ self.log.debug('{} lsb_release: {}'.format(
975+ sentry_unit.info['unit_name'], release))
976+ else:
977+ msg = ('{} `{}` returned {} '
978+ '{}'.format(sentry_unit.info['unit_name'],
979+ cmd, release, code))
980+ if release not in self.ubuntu_releases:
981+ msg = ("Release ({}) not found in Ubuntu releases "
982+ "({})".format(release, self.ubuntu_releases))
983+ return release, msg
984+
985+ def validate_services(self, commands):
986+ """Validate that lists of commands succeed on service units. Can be
987+ used to verify system services are running on the corresponding
988+ service units.
989+
990+ :param commands: dict with sentry keys and arbitrary command list vals
991+ :returns: None if successful, Failure string message otherwise
992+ """
993+ self.log.debug('Checking status of system services...')
994+
995+ # /!\ DEPRECATION WARNING (beisner):
996+ # New and existing tests should be rewritten to use
997+ # validate_services_by_name() as it is aware of init systems.
998+ self.log.warn('DEPRECATION WARNING: use '
999+ 'validate_services_by_name instead of validate_services '
1000+ 'due to init system differences.')
1001+
1002+ for k, v in six.iteritems(commands):
1003+ for cmd in v:
1004+ output, code = k.run(cmd)
1005+ self.log.debug('{} `{}` returned '
1006+ '{}'.format(k.info['unit_name'],
1007+ cmd, code))
1008+ if code != 0:
1009+ return "command `{}` returned {}".format(cmd, str(code))
1010+ return None
1011+
1012+ def validate_services_by_name(self, sentry_services):
1013+ """Validate system service status by service name, automatically
1014+ detecting init system based on Ubuntu release codename.
1015+
1016+ :param sentry_services: dict with sentry keys and svc list values
1017+ :returns: None if successful, Failure string message otherwise
1018+ """
1019+ self.log.debug('Checking status of system services...')
1020+
1021+ # Point at which systemd became a thing
1022+ systemd_switch = self.ubuntu_releases.index('vivid')
1023+
1024+ for sentry_unit, services_list in six.iteritems(sentry_services):
1025+ # Get lsb_release codename from unit
1026+ release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
1027+ if ret:
1028+ return ret
1029+
1030+ for service_name in services_list:
1031+ if (self.ubuntu_releases.index(release) >= systemd_switch or
1032+ service_name in ['rabbitmq-server', 'apache2',
1033+ 'memcached']):
1034+ # init is systemd (or regular sysv)
1035+ cmd = 'sudo service {} status'.format(service_name)
1036+ output, code = sentry_unit.run(cmd)
1037+ service_running = code == 0
1038+ elif self.ubuntu_releases.index(release) < systemd_switch:
1039+ # init is upstart
1040+ cmd = 'sudo status {}'.format(service_name)
1041+ output, code = sentry_unit.run(cmd)
1042+ service_running = code == 0 and "start/running" in output
1043+
1044+ self.log.debug('{} `{}` returned '
1045+ '{}'.format(sentry_unit.info['unit_name'],
1046+ cmd, code))
1047+ if not service_running:
1048+ return u"command `{}` returned {} {}".format(
1049+ cmd, output, str(code))
1050+ return None
1051+
1052+ def _get_config(self, unit, filename):
1053+ """Get a ConfigParser object for parsing a unit's config file."""
1054+ file_contents = unit.file_contents(filename)
1055+
1056+ # NOTE(beisner): by default, ConfigParser does not handle options
1057+ # with no value, such as the flags used in the mysql my.cnf file.
1058+ # https://bugs.python.org/issue7005
1059+ config = configparser.ConfigParser(allow_no_value=True)
1060+ config.readfp(io.StringIO(file_contents))
1061+ return config
1062+
1063+ def validate_config_data(self, sentry_unit, config_file, section,
1064+ expected):
1065+ """Validate config file data.
1066+
1067+ Verify that the specified section of the config file contains
1068+ the expected option key:value pairs.
1069+
1070+ Compare expected dictionary data vs actual dictionary data.
1071+ The values in the 'expected' dictionary can be strings, bools, ints,
1072+ longs, or can be a function that evaluates a variable and returns a
1073+ bool.
1074+ """
1075+ self.log.debug('Validating config file data ({} in {} on {})'
1076+ '...'.format(section, config_file,
1077+ sentry_unit.info['unit_name']))
1078+ config = self._get_config(sentry_unit, config_file)
1079+
1080+ if section != 'DEFAULT' and not config.has_section(section):
1081+ return "section [{}] does not exist".format(section)
1082+
1083+ for k in expected.keys():
1084+ if not config.has_option(section, k):
1085+ return "section [{}] is missing option {}".format(section, k)
1086+
1087+ actual = config.get(section, k)
1088+ v = expected[k]
1089+ if (isinstance(v, six.string_types) or
1090+ isinstance(v, bool) or
1091+ isinstance(v, six.integer_types)):
1092+ # handle explicit values
1093+ if actual != v:
1094+ return "section [{}] {}:{} != expected {}:{}".format(
1095+ section, k, actual, k, expected[k])
1096+ # handle function pointers, such as not_null or valid_ip
1097+ elif not v(actual):
1098+ return "section [{}] {}:{} != expected {}:{}".format(
1099+ section, k, actual, k, expected[k])
1100+ return None
1101+
1102+ def _validate_dict_data(self, expected, actual):
1103+ """Validate dictionary data.
1104+
1105+ Compare expected dictionary data vs actual dictionary data.
1106+ The values in the 'expected' dictionary can be strings, bools, ints,
1107+ longs, or can be a function that evaluates a variable and returns a
1108+ bool.
1109+ """
1110+ self.log.debug('actual: {}'.format(repr(actual)))
1111+ self.log.debug('expected: {}'.format(repr(expected)))
1112+
1113+ for k, v in six.iteritems(expected):
1114+ if k in actual:
1115+ if (isinstance(v, six.string_types) or
1116+ isinstance(v, bool) or
1117+ isinstance(v, six.integer_types)):
1118+ # handle explicit values
1119+ if v != actual[k]:
1120+ return "{}:{}".format(k, actual[k])
1121+ # handle function pointers, such as not_null or valid_ip
1122+ elif not v(actual[k]):
1123+ return "{}:{}".format(k, actual[k])
1124+ else:
1125+ return "key '{}' does not exist".format(k)
1126+ return None
1127+
1128+ def validate_relation_data(self, sentry_unit, relation, expected):
1129+ """Validate actual relation data based on expected relation data."""
1130+ actual = sentry_unit.relation(relation[0], relation[1])
1131+ return self._validate_dict_data(expected, actual)
1132+
1133+ def _validate_list_data(self, expected, actual):
1134+ """Compare expected list vs actual list data."""
1135+ for e in expected:
1136+ if e not in actual:
1137+ return "expected item {} not found in actual list".format(e)
1138+ return None
1139+
1140+ def not_null(self, string):
1141+ if string is not None:
1142+ return True
1143+ else:
1144+ return False
1145+
1146+ def _get_file_mtime(self, sentry_unit, filename):
1147+ """Get last modification time of file."""
1148+ return sentry_unit.file_stat(filename)['mtime']
1149+
1150+ def _get_dir_mtime(self, sentry_unit, directory):
1151+ """Get last modification time of directory."""
1152+ return sentry_unit.directory_stat(directory)['mtime']
1153+
1154+ def _get_proc_start_time(self, sentry_unit, service, pgrep_full=None):
1155+ """Get start time of a process based on the last modification time
1156+ of the /proc/pid directory.
1157+
1158+ :sentry_unit: The sentry unit to check for the service on
1159+ :service: service name to look for in process table
1160+ :pgrep_full: [Deprecated] Use full command line search mode with pgrep
1161+ :returns: epoch time of service process start
1162+ :param commands: list of bash commands
1163+ :param sentry_units: list of sentry unit pointers
1164+ :returns: None if successful; Failure message otherwise
1165+ """
1166+ pid_list = self.get_process_id_list(
1167+ sentry_unit, service, pgrep_full=pgrep_full)
1168+ pid = pid_list[0]
1169+ proc_dir = '/proc/{}'.format(pid)
1170+ self.log.debug('Pid for {} on {}: {}'.format(
1171+ service, sentry_unit.info['unit_name'], pid))
1172+
1173+ return self._get_dir_mtime(sentry_unit, proc_dir)
1174+
1175+ def service_restarted(self, sentry_unit, service, filename,
1176+ pgrep_full=None, sleep_time=20):
1177+ """Check if service was restarted.
1178+
1179+ Compare a service's start time vs a file's last modification time
1180+ (such as a config file for that service) to determine if the service
1181+ has been restarted.
1182+ """
1183+ # /!\ DEPRECATION WARNING (beisner):
1184+ # This method is prone to races in that no before-time is known.
1185+ # Use validate_service_config_changed instead.
1186+
1187+ # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
1188+ # used instead of pgrep. pgrep_full is still passed through to ensure
1189+ # deprecation WARNS. lp1474030
1190+ self.log.warn('DEPRECATION WARNING: use '
1191+ 'validate_service_config_changed instead of '
1192+ 'service_restarted due to known races.')
1193+
1194+ time.sleep(sleep_time)
1195+ if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
1196+ self._get_file_mtime(sentry_unit, filename)):
1197+ return True
1198+ else:
1199+ return False
1200+
1201+ def service_restarted_since(self, sentry_unit, mtime, service,
1202+ pgrep_full=None, sleep_time=20,
1203+ retry_count=30, retry_sleep_time=10):
1204+ """Check if service was been started after a given time.
1205+
1206+ Args:
1207+ sentry_unit (sentry): The sentry unit to check for the service on
1208+ mtime (float): The epoch time to check against
1209+ service (string): service name to look for in process table
1210+ pgrep_full: [Deprecated] Use full command line search mode with pgrep
1211+ sleep_time (int): Initial sleep time (s) before looking for file
1212+ retry_sleep_time (int): Time (s) to sleep between retries
1213+ retry_count (int): If file is not found, how many times to retry
1214+
1215+ Returns:
1216+ bool: True if service found and its start time it newer than mtime,
1217+ False if service is older than mtime or if service was
1218+ not found.
1219+ """
1220+ # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
1221+ # used instead of pgrep. pgrep_full is still passed through to ensure
1222+ # deprecation WARNS. lp1474030
1223+
1224+ unit_name = sentry_unit.info['unit_name']
1225+ self.log.debug('Checking that %s service restarted since %s on '
1226+ '%s' % (service, mtime, unit_name))
1227+ time.sleep(sleep_time)
1228+ proc_start_time = None
1229+ tries = 0
1230+ while tries <= retry_count and not proc_start_time:
1231+ try:
1232+ proc_start_time = self._get_proc_start_time(sentry_unit,
1233+ service,
1234+ pgrep_full)
1235+ self.log.debug('Attempt {} to get {} proc start time on {} '
1236+ 'OK'.format(tries, service, unit_name))
1237+ except IOError as e:
1238+ # NOTE(beisner) - race avoidance, proc may not exist yet.
1239+ # https://bugs.launchpad.net/charm-helpers/+bug/1474030
1240+ self.log.debug('Attempt {} to get {} proc start time on {} '
1241+ 'failed\n{}'.format(tries, service,
1242+ unit_name, e))
1243+ time.sleep(retry_sleep_time)
1244+ tries += 1
1245+
1246+ if not proc_start_time:
1247+ self.log.warn('No proc start time found, assuming service did '
1248+ 'not start')
1249+ return False
1250+ if proc_start_time >= mtime:
1251+ self.log.debug('Proc start time is newer than provided mtime'
1252+ '(%s >= %s) on %s (OK)' % (proc_start_time,
1253+ mtime, unit_name))
1254+ return True
1255+ else:
1256+ self.log.warn('Proc start time (%s) is older than provided mtime '
1257+ '(%s) on %s, service did not '
1258+ 'restart' % (proc_start_time, mtime, unit_name))
1259+ return False
1260+
1261+ def config_updated_since(self, sentry_unit, filename, mtime,
1262+ sleep_time=20, retry_count=30,
1263+ retry_sleep_time=10):
1264+ """Check if file was modified after a given time.
1265+
1266+ Args:
1267+ sentry_unit (sentry): The sentry unit to check the file mtime on
1268+ filename (string): The file to check mtime of
1269+ mtime (float): The epoch time to check against
1270+ sleep_time (int): Initial sleep time (s) before looking for file
1271+ retry_sleep_time (int): Time (s) to sleep between retries
1272+ retry_count (int): If file is not found, how many times to retry
1273+
1274+ Returns:
1275+ bool: True if file was modified more recently than mtime, False if
1276+ file was modified before mtime, or if file not found.
1277+ """
1278+ unit_name = sentry_unit.info['unit_name']
1279+ self.log.debug('Checking that %s updated since %s on '
1280+ '%s' % (filename, mtime, unit_name))
1281+ time.sleep(sleep_time)
1282+ file_mtime = None
1283+ tries = 0
1284+ while tries <= retry_count and not file_mtime:
1285+ try:
1286+ file_mtime = self._get_file_mtime(sentry_unit, filename)
1287+ self.log.debug('Attempt {} to get {} file mtime on {} '
1288+ 'OK'.format(tries, filename, unit_name))
1289+ except IOError as e:
1290+ # NOTE(beisner) - race avoidance, file may not exist yet.
1291+ # https://bugs.launchpad.net/charm-helpers/+bug/1474030
1292+ self.log.debug('Attempt {} to get {} file mtime on {} '
1293+ 'failed\n{}'.format(tries, filename,
1294+ unit_name, e))
1295+ time.sleep(retry_sleep_time)
1296+ tries += 1
1297+
1298+ if not file_mtime:
1299+ self.log.warn('Could not determine file mtime, assuming '
1300+ 'file does not exist')
1301+ return False
1302+
1303+ if file_mtime >= mtime:
1304+ self.log.debug('File mtime is newer than provided mtime '
1305+ '(%s >= %s) on %s (OK)' % (file_mtime,
1306+ mtime, unit_name))
1307+ return True
1308+ else:
1309+ self.log.warn('File mtime is older than provided mtime'
1310+ '(%s < on %s) on %s' % (file_mtime,
1311+ mtime, unit_name))
1312+ return False
1313+
1314+ def validate_service_config_changed(self, sentry_unit, mtime, service,
1315+ filename, pgrep_full=None,
1316+ sleep_time=20, retry_count=30,
1317+ retry_sleep_time=10):
1318+ """Check service and file were updated after mtime
1319+
1320+ Args:
1321+ sentry_unit (sentry): The sentry unit to check for the service on
1322+ mtime (float): The epoch time to check against
1323+ service (string): service name to look for in process table
1324+ filename (string): The file to check mtime of
1325+ pgrep_full: [Deprecated] Use full command line search mode with pgrep
1326+ sleep_time (int): Initial sleep in seconds to pass to test helpers
1327+ retry_count (int): If service is not found, how many times to retry
1328+ retry_sleep_time (int): Time in seconds to wait between retries
1329+
1330+ Typical Usage:
1331+ u = OpenStackAmuletUtils(ERROR)
1332+ ...
1333+ mtime = u.get_sentry_time(self.cinder_sentry)
1334+ self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
1335+ if not u.validate_service_config_changed(self.cinder_sentry,
1336+ mtime,
1337+ 'cinder-api',
1338+ '/etc/cinder/cinder.conf')
1339+ amulet.raise_status(amulet.FAIL, msg='update failed')
1340+ Returns:
1341+ bool: True if both service and file where updated/restarted after
1342+ mtime, False if service is older than mtime or if service was
1343+ not found or if filename was modified before mtime.
1344+ """
1345+
1346+ # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
1347+ # used instead of pgrep. pgrep_full is still passed through to ensure
1348+ # deprecation WARNS. lp1474030
1349+
1350+ service_restart = self.service_restarted_since(
1351+ sentry_unit, mtime,
1352+ service,
1353+ pgrep_full=pgrep_full,
1354+ sleep_time=sleep_time,
1355+ retry_count=retry_count,
1356+ retry_sleep_time=retry_sleep_time)
1357+
1358+ config_update = self.config_updated_since(
1359+ sentry_unit,
1360+ filename,
1361+ mtime,
1362+ sleep_time=sleep_time,
1363+ retry_count=retry_count,
1364+ retry_sleep_time=retry_sleep_time)
1365+
1366+ return service_restart and config_update
1367+
1368+ def get_sentry_time(self, sentry_unit):
1369+ """Return current epoch time on a sentry"""
1370+ cmd = "date +'%s'"
1371+ return float(sentry_unit.run(cmd)[0])
1372+
1373+ def relation_error(self, name, data):
1374+ return 'unexpected relation data in {} - {}'.format(name, data)
1375+
1376+ def endpoint_error(self, name, data):
1377+ return 'unexpected endpoint data in {} - {}'.format(name, data)
1378+
1379+ def get_ubuntu_releases(self):
1380+ """Return a list of all Ubuntu releases in order of release."""
1381+ _d = distro_info.UbuntuDistroInfo()
1382+ _release_list = _d.all
1383+ return _release_list
1384+
1385+ def file_to_url(self, file_rel_path):
1386+ """Convert a relative file path to a file URL."""
1387+ _abs_path = os.path.abspath(file_rel_path)
1388+ return urlparse.urlparse(_abs_path, scheme='file').geturl()
1389+
1390+ def check_commands_on_units(self, commands, sentry_units):
1391+ """Check that all commands in a list exit zero on all
1392+ sentry units in a list.
1393+
1394+ :param commands: list of bash commands
1395+ :param sentry_units: list of sentry unit pointers
1396+ :returns: None if successful; Failure message otherwise
1397+ """
1398+ self.log.debug('Checking exit codes for {} commands on {} '
1399+ 'sentry units...'.format(len(commands),
1400+ len(sentry_units)))
1401+ for sentry_unit in sentry_units:
1402+ for cmd in commands:
1403+ output, code = sentry_unit.run(cmd)
1404+ if code == 0:
1405+ self.log.debug('{} `{}` returned {} '
1406+ '(OK)'.format(sentry_unit.info['unit_name'],
1407+ cmd, code))
1408+ else:
1409+ return ('{} `{}` returned {} '
1410+ '{}'.format(sentry_unit.info['unit_name'],
1411+ cmd, code, output))
1412+ return None
1413+
1414+ def get_process_id_list(self, sentry_unit, process_name,
1415+ expect_success=True, pgrep_full=False):
1416+ """Get a list of process ID(s) from a single sentry juju unit
1417+ for a single process name.
1418+
1419+ :param sentry_unit: Amulet sentry instance (juju unit)
1420+ :param process_name: Process name
1421+ :param expect_success: If False, expect the PID to be missing,
1422+ raise if it is present.
1423+ :returns: List of process IDs
1424+ """
1425+ if pgrep_full:
1426+ cmd = 'pgrep -f "{}"'.format(process_name)
1427+ else:
1428+ cmd = 'pidof -x "{}"'.format(process_name)
1429+ if not expect_success:
1430+ cmd += " || exit 0 && exit 1"
1431+ output, code = sentry_unit.run(cmd)
1432+ if code != 0:
1433+ msg = ('{} `{}` returned {} '
1434+ '{}'.format(sentry_unit.info['unit_name'],
1435+ cmd, code, output))
1436+ amulet.raise_status(amulet.FAIL, msg=msg)
1437+ return str(output).split()
1438+
1439+ def get_unit_process_ids(
1440+ self, unit_processes, expect_success=True, pgrep_full=False):
1441+ """Construct a dict containing unit sentries, process names, and
1442+ process IDs.
1443+
1444+ :param unit_processes: A dictionary of Amulet sentry instance
1445+ to list of process names.
1446+ :param expect_success: if False expect the processes to not be
1447+ running, raise if they are.
1448+ :returns: Dictionary of Amulet sentry instance to dictionary
1449+ of process names to PIDs.
1450+ """
1451+ pid_dict = {}
1452+ for sentry_unit, process_list in six.iteritems(unit_processes):
1453+ pid_dict[sentry_unit] = {}
1454+ for process in process_list:
1455+ pids = self.get_process_id_list(
1456+ sentry_unit, process, expect_success=expect_success,
1457+ pgrep_full=pgrep_full)
1458+ pid_dict[sentry_unit].update({process: pids})
1459+ return pid_dict
1460+
1461+ def validate_unit_process_ids(self, expected, actual):
1462+ """Validate process id quantities for services on units."""
1463+ self.log.debug('Checking units for running processes...')
1464+ self.log.debug('Expected PIDs: {}'.format(expected))
1465+ self.log.debug('Actual PIDs: {}'.format(actual))
1466+
1467+ if len(actual) != len(expected):
1468+ return ('Unit count mismatch. expected, actual: {}, '
1469+ '{} '.format(len(expected), len(actual)))
1470+
1471+ for (e_sentry, e_proc_names) in six.iteritems(expected):
1472+ e_sentry_name = e_sentry.info['unit_name']
1473+ if e_sentry in actual.keys():
1474+ a_proc_names = actual[e_sentry]
1475+ else:
1476+ return ('Expected sentry ({}) not found in actual dict data.'
1477+ '{}'.format(e_sentry_name, e_sentry))
1478+
1479+ if len(e_proc_names.keys()) != len(a_proc_names.keys()):
1480+ return ('Process name count mismatch. expected, actual: {}, '
1481+ '{}'.format(len(expected), len(actual)))
1482+
1483+ for (e_proc_name, e_pids), (a_proc_name, a_pids) in \
1484+ zip(e_proc_names.items(), a_proc_names.items()):
1485+ if e_proc_name != a_proc_name:
1486+ return ('Process name mismatch. expected, actual: {}, '
1487+ '{}'.format(e_proc_name, a_proc_name))
1488+
1489+ a_pids_length = len(a_pids)
1490+ fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
1491+ '{}, {} ({})'.format(e_sentry_name, e_proc_name,
1492+ e_pids, a_pids_length,
1493+ a_pids))
1494+
1495+ # If expected is a list, ensure at least one PID quantity match
1496+ if isinstance(e_pids, list) and \
1497+ a_pids_length not in e_pids:
1498+ return fail_msg
1499+ # If expected is not bool and not list,
1500+ # ensure PID quantities match
1501+ elif not isinstance(e_pids, bool) and \
1502+ not isinstance(e_pids, list) and \
1503+ a_pids_length != e_pids:
1504+ return fail_msg
1505+ # If expected is bool True, ensure 1 or more PIDs exist
1506+ elif isinstance(e_pids, bool) and \
1507+ e_pids is True and a_pids_length < 1:
1508+ return fail_msg
1509+ # If expected is bool False, ensure 0 PIDs exist
1510+ elif isinstance(e_pids, bool) and \
1511+ e_pids is False and a_pids_length != 0:
1512+ return fail_msg
1513+ else:
1514+ self.log.debug('PID check OK: {} {} {}: '
1515+ '{}'.format(e_sentry_name, e_proc_name,
1516+ e_pids, a_pids))
1517+ return None
1518+
1519+ def validate_list_of_identical_dicts(self, list_of_dicts):
1520+ """Check that all dicts within a list are identical."""
1521+ hashes = []
1522+ for _dict in list_of_dicts:
1523+ hashes.append(hash(frozenset(_dict.items())))
1524+
1525+ self.log.debug('Hashes: {}'.format(hashes))
1526+ if len(set(hashes)) == 1:
1527+ self.log.debug('Dicts within list are identical')
1528+ else:
1529+ return 'Dicts within list are not identical'
1530+
1531+ return None
1532+
1533+ def validate_sectionless_conf(self, file_contents, expected):
1534+ """A crude conf parser. Useful to inspect configuration files which
1535+ do not have section headers (as would be necessary in order to use
1536+ the configparser). Such as openstack-dashboard or rabbitmq confs."""
1537+ for line in file_contents.split('\n'):
1538+ if '=' in line:
1539+ args = line.split('=')
1540+ if len(args) <= 1:
1541+ continue
1542+ key = args[0].strip()
1543+ value = args[1].strip()
1544+ if key in expected.keys():
1545+ if expected[key] != value:
1546+ msg = ('Config mismatch. Expected, actual: {}, '
1547+ '{}'.format(expected[key], value))
1548+ amulet.raise_status(amulet.FAIL, msg=msg)
1549+
1550+ def get_unit_hostnames(self, units):
1551+ """Return a dict of juju unit names to hostnames."""
1552+ host_names = {}
1553+ for unit in units:
1554+ host_names[unit.info['unit_name']] = \
1555+ str(unit.file_contents('/etc/hostname').strip())
1556+ self.log.debug('Unit host names: {}'.format(host_names))
1557+ return host_names
1558+
1559+ def run_cmd_unit(self, sentry_unit, cmd):
1560+ """Run a command on a unit, return the output and exit code."""
1561+ output, code = sentry_unit.run(cmd)
1562+ if code == 0:
1563+ self.log.debug('{} `{}` command returned {} '
1564+ '(OK)'.format(sentry_unit.info['unit_name'],
1565+ cmd, code))
1566+ else:
1567+ msg = ('{} `{}` command returned {} '
1568+ '{}'.format(sentry_unit.info['unit_name'],
1569+ cmd, code, output))
1570+ amulet.raise_status(amulet.FAIL, msg=msg)
1571+ return str(output), code
1572+
1573+ def file_exists_on_unit(self, sentry_unit, file_name):
1574+ """Check if a file exists on a unit."""
1575+ try:
1576+ sentry_unit.file_stat(file_name)
1577+ return True
1578+ except IOError:
1579+ return False
1580+ except Exception as e:
1581+ msg = 'Error checking file {}: {}'.format(file_name, e)
1582+ amulet.raise_status(amulet.FAIL, msg=msg)
1583+
1584+ def file_contents_safe(self, sentry_unit, file_name,
1585+ max_wait=60, fatal=False):
1586+ """Get file contents from a sentry unit. Wrap amulet file_contents
1587+ with retry logic to address races where a file checks as existing,
1588+ but no longer exists by the time file_contents is called.
1589+ Return None if file not found. Optionally raise if fatal is True."""
1590+ unit_name = sentry_unit.info['unit_name']
1591+ file_contents = False
1592+ tries = 0
1593+ while not file_contents and tries < (max_wait / 4):
1594+ try:
1595+ file_contents = sentry_unit.file_contents(file_name)
1596+ except IOError:
1597+ self.log.debug('Attempt {} to open file {} from {} '
1598+ 'failed'.format(tries, file_name,
1599+ unit_name))
1600+ time.sleep(4)
1601+ tries += 1
1602+
1603+ if file_contents:
1604+ return file_contents
1605+ elif not fatal:
1606+ return None
1607+ elif fatal:
1608+ msg = 'Failed to get file contents from unit.'
1609+ amulet.raise_status(amulet.FAIL, msg)
1610+
1611+ def port_knock_tcp(self, host="localhost", port=22, timeout=15):
1612+ """Open a TCP socket to check for a listening sevice on a host.
1613+
1614+ :param host: host name or IP address, default to localhost
1615+ :param port: TCP port number, default to 22
1616+ :param timeout: Connect timeout, default to 15 seconds
1617+ :returns: True if successful, False if connect failed
1618+ """
1619+
1620+ # Resolve host name if possible
1621+ try:
1622+ connect_host = socket.gethostbyname(host)
1623+ host_human = "{} ({})".format(connect_host, host)
1624+ except socket.error as e:
1625+ self.log.warn('Unable to resolve address: '
1626+ '{} ({}) Trying anyway!'.format(host, e))
1627+ connect_host = host
1628+ host_human = connect_host
1629+
1630+ # Attempt socket connection
1631+ try:
1632+ knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1633+ knock.settimeout(timeout)
1634+ knock.connect((connect_host, port))
1635+ knock.close()
1636+ self.log.debug('Socket connect OK for host '
1637+ '{} on port {}.'.format(host_human, port))
1638+ return True
1639+ except socket.error as e:
1640+ self.log.debug('Socket connect FAIL for'
1641+ ' {} port {} ({})'.format(host_human, port, e))
1642+ return False
1643+
1644+ def port_knock_units(self, sentry_units, port=22,
1645+ timeout=15, expect_success=True):
1646+ """Open a TCP socket to check for a listening sevice on each
1647+ listed juju unit.
1648+
1649+ :param sentry_units: list of sentry unit pointers
1650+ :param port: TCP port number, default to 22
1651+ :param timeout: Connect timeout, default to 15 seconds
1652+ :expect_success: True by default, set False to invert logic
1653+ :returns: None if successful, Failure message otherwise
1654+ """
1655+ for unit in sentry_units:
1656+ host = unit.info['public-address']
1657+ connected = self.port_knock_tcp(host, port, timeout)
1658+ if not connected and expect_success:
1659+ return 'Socket connect failed.'
1660+ elif connected and not expect_success:
1661+ return 'Socket connected unexpectedly.'
1662+
1663+ def get_uuid_epoch_stamp(self):
1664+ """Returns a stamp string based on uuid4 and epoch time. Useful in
1665+ generating test messages which need to be unique-ish."""
1666+ return '[{}-{}]'.format(uuid.uuid4(), time.time())
1667+
1668+ # amulet juju action helpers:
1669+ def run_action(self, unit_sentry, action,
1670+ _check_output=subprocess.check_output,
1671+ params=None):
1672+ """Translate to amulet's built in run_action(). Deprecated.
1673+
1674+ Run the named action on a given unit sentry.
1675+
1676+ params a dict of parameters to use
1677+ _check_output parameter is no longer used
1678+
1679+ @return action_id.
1680+ """
1681+ self.log.warn('charmhelpers.contrib.amulet.utils.run_action has been '
1682+ 'deprecated for amulet.run_action')
1683+ return unit_sentry.run_action(action, action_args=params)
1684+
1685+ def wait_on_action(self, action_id, _check_output=subprocess.check_output):
1686+ """Wait for a given action, returning if it completed or not.
1687+
1688+ action_id a string action uuid
1689+ _check_output parameter is no longer used
1690+ """
1691+ data = amulet.actions.get_action_output(action_id, full_output=True)
1692+ return data.get(u"status") == "completed"
1693+
1694+ def status_get(self, unit):
1695+ """Return the current service status of this unit."""
1696+ raw_status, return_code = unit.run(
1697+ "status-get --format=json --include-data")
1698+ if return_code != 0:
1699+ return ("unknown", "")
1700+ status = json.loads(raw_status)
1701+ return (status["status"], status["message"])
1702
1703=== modified file 'hooks/charmhelpers/contrib/ansible/__init__.py'
1704--- hooks/charmhelpers/contrib/ansible/__init__.py 2016-12-20 14:35:00 +0000
1705+++ hooks/charmhelpers/contrib/ansible/__init__.py 2021-05-12 04:07:51 +0000
1706@@ -16,90 +16,107 @@
1707 #
1708 # Authors:
1709 # Charm Helpers Developers <juju@lists.ubuntu.com>
1710-"""Charm Helpers ansible - declare the state of your machines.
1711-
1712-This helper enables you to declare your machine state, rather than
1713-program it procedurally (and have to test each change to your procedures).
1714-Your install hook can be as simple as::
1715-
1716- {{{
1717- import charmhelpers.contrib.ansible
1718-
1719-
1720+"""
1721+The ansible package enables you to easily use the configuration management
1722+tool `Ansible`_ to setup and configure your charm. All of your charm
1723+configuration options and relation-data are available as regular Ansible
1724+variables which can be used in your playbooks and templates.
1725+
1726+.. _Ansible: https://www.ansible.com/
1727+
1728+Usage
1729+=====
1730+
1731+Here is an example directory structure for a charm to get you started::
1732+
1733+ charm-ansible-example/
1734+ |-- ansible
1735+ | |-- playbook.yaml
1736+ | `-- templates
1737+ | `-- example.j2
1738+ |-- config.yaml
1739+ |-- copyright
1740+ |-- icon.svg
1741+ |-- layer.yaml
1742+ |-- metadata.yaml
1743+ |-- reactive
1744+ | `-- example.py
1745+ |-- README.md
1746+
1747+Running a playbook called ``playbook.yaml`` when the ``install`` hook is run
1748+can be as simple as::
1749+
1750+ from charmhelpers.contrib import ansible
1751+ from charms.reactive import hook
1752+
1753+ @hook('install')
1754 def install():
1755- charmhelpers.contrib.ansible.install_ansible_support()
1756- charmhelpers.contrib.ansible.apply_playbook('playbooks/install.yaml')
1757- }}}
1758-
1759-and won't need to change (nor will its tests) when you change the machine
1760-state.
1761-
1762-All of your juju config and relation-data are available as template
1763-variables within your playbooks and templates. An install playbook looks
1764-something like::
1765-
1766- {{{
1767+ ansible.install_ansible_support()
1768+ ansible.apply_playbook('ansible/playbook.yaml')
1769+
1770+Here is an example playbook that uses the ``template`` module to template the
1771+file ``example.j2`` to the charm host and then uses the ``debug`` module to
1772+print out all the host and Juju variables that you can use in your playbooks.
1773+Note that you must target ``localhost`` as the playbook is run locally on the
1774+charm host::
1775+
1776 ---
1777 - hosts: localhost
1778- user: root
1779-
1780 tasks:
1781- - name: Add private repositories.
1782+ - name: Template a file
1783 template:
1784- src: ../templates/private-repositories.list.jinja2
1785- dest: /etc/apt/sources.list.d/private.list
1786-
1787- - name: Update the cache.
1788- apt: update_cache=yes
1789-
1790- - name: Install dependencies.
1791- apt: pkg={{ item }}
1792- with_items:
1793- - python-mimeparse
1794- - python-webob
1795- - sunburnt
1796-
1797- - name: Setup groups.
1798- group: name={{ item.name }} gid={{ item.gid }}
1799- with_items:
1800- - { name: 'deploy_user', gid: 1800 }
1801- - { name: 'service_user', gid: 1500 }
1802-
1803- ...
1804- }}}
1805-
1806-Read more online about `playbooks`_ and standard ansible `modules`_.
1807-
1808-.. _playbooks: http://www.ansibleworks.com/docs/playbooks.html
1809-.. _modules: http://www.ansibleworks.com/docs/modules.html
1810-
1811-A further feature os the ansible hooks is to provide a light weight "action"
1812+ src: templates/example.j2
1813+ dest: /tmp/example.j2
1814+
1815+ - name: Print all variables available to Ansible
1816+ debug:
1817+ var: vars
1818+
1819+Read more online about `playbooks`_ and standard Ansible `modules`_.
1820+
1821+.. _playbooks: https://docs.ansible.com/ansible/latest/user_guide/playbooks.html
1822+.. _modules: https://docs.ansible.com/ansible/latest/user_guide/modules.html
1823+
1824+A further feature of the Ansible hooks is to provide a light weight "action"
1825 scripting tool. This is a decorator that you apply to a function, and that
1826-function can now receive cli args, and can pass extra args to the playbook.
1827-
1828-e.g.
1829-
1830-
1831-@hooks.action()
1832-def some_action(amount, force="False"):
1833- "Usage: some-action AMOUNT [force=True]" # <-- shown on error
1834- # process the arguments
1835- # do some calls
1836- # return extra-vars to be passed to ansible-playbook
1837- return {
1838- 'amount': int(amount),
1839- 'type': force,
1840- }
1841+function can now receive cli args, and can pass extra args to the playbook::
1842+
1843+ @hooks.action()
1844+ def some_action(amount, force="False"):
1845+ "Usage: some-action AMOUNT [force=True]" # <-- shown on error
1846+ # process the arguments
1847+ # do some calls
1848+ # return extra-vars to be passed to ansible-playbook
1849+ return {
1850+ 'amount': int(amount),
1851+ 'type': force,
1852+ }
1853
1854 You can now create a symlink to hooks.py that can be invoked like a hook, but
1855-with cli params:
1856-
1857-# link actions/some-action to hooks/hooks.py
1858-
1859-actions/some-action amount=10 force=true
1860+with cli params::
1861+
1862+ # link actions/some-action to hooks/hooks.py
1863+
1864+ actions/some-action amount=10 force=true
1865+
1866+Install Ansible via pip
1867+=======================
1868+
1869+If you want to install a specific version of Ansible via pip instead of
1870+``install_ansible_support`` which uses APT, consider using the layer options
1871+of `layer-basic`_ to install Ansible in a virtualenv::
1872+
1873+ options:
1874+ basic:
1875+ python_packages: ['ansible==2.9.0']
1876+ include_system_packages: true
1877+ use_venv: true
1878+
1879+.. _layer-basic: https://charmsreactive.readthedocs.io/en/latest/layer-basic.html#layer-configuration
1880
1881 """
1882 import os
1883+import json
1884 import stat
1885 import subprocess
1886 import functools
1887@@ -117,27 +134,63 @@
1888 ansible_vars_path = '/etc/ansible/host_vars/localhost'
1889
1890
1891-def install_ansible_support(from_ppa=True, ppa_location='ppa:rquillo/ansible'):
1892- """Installs the ansible package.
1893-
1894- By default it is installed from the `PPA`_ linked from
1895- the ansible `website`_ or from a ppa specified by a charm config..
1896-
1897- .. _PPA: https://launchpad.net/~rquillo/+archive/ansible
1898+def install_ansible_support(from_ppa=True, ppa_location='ppa:ansible/ansible'):
1899+ """Installs Ansible via APT.
1900+
1901+ By default this installs Ansible from the `PPA`_ linked from
1902+ the Ansible `website`_ or from a PPA set in ``ppa_location``.
1903+
1904+ .. _PPA: https://launchpad.net/~ansible/+archive/ubuntu/ansible
1905 .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu
1906
1907- If from_ppa is empty, you must ensure that the package is available
1908- from a configured repository.
1909+ If ``from_ppa`` is ``False``, then Ansible will be installed from
1910+ Ubuntu's Universe repositories.
1911 """
1912 if from_ppa:
1913 charmhelpers.fetch.add_source(ppa_location)
1914 charmhelpers.fetch.apt_update(fatal=True)
1915 charmhelpers.fetch.apt_install('ansible')
1916 with open(ansible_hosts_path, 'w+') as hosts_file:
1917- hosts_file.write('localhost ansible_connection=local')
1918+ hosts_file.write('localhost ansible_connection=local ansible_remote_tmp=/root/.ansible/tmp')
1919
1920
1921 def apply_playbook(playbook, tags=None, extra_vars=None):
1922+ """Run a playbook.
1923+
1924+ This helper runs a playbook with juju state variables as context,
1925+ therefore variables set in application config can be used directly.
1926+ List of tags (--tags) and dictionary with extra_vars (--extra-vars)
1927+ can be passed as additional parameters.
1928+
1929+ Read more about playbook `_variables`_ online.
1930+
1931+ .. _variables: https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html
1932+
1933+ Example::
1934+
1935+ # Run ansible/playbook.yaml with tag install and pass extra
1936+ # variables var_a and var_b
1937+ apply_playbook(
1938+ playbook='ansible/playbook.yaml',
1939+ tags=['install'],
1940+ extra_vars={'var_a': 'val_a', 'var_b': 'val_b'}
1941+ )
1942+
1943+ # Run ansible/playbook.yaml with tag config and extra variable nested,
1944+ # which is passed as json and can be used as dictionary in playbook
1945+ apply_playbook(
1946+ playbook='ansible/playbook.yaml',
1947+ tags=['config'],
1948+ extra_vars={'nested': {'a': 'value1', 'b': 'value2'}}
1949+ )
1950+
1951+ # Custom config file can be passed within extra_vars
1952+ apply_playbook(
1953+ playbook='ansible/playbook.yaml',
1954+ extra_vars="@some_file.json"
1955+ )
1956+
1957+ """
1958 tags = tags or []
1959 tags = ",".join(tags)
1960 charmhelpers.contrib.templating.contexts.juju_state_to_yaml(
1961@@ -146,6 +199,9 @@
1962
1963 # we want ansible's log output to be unbuffered
1964 env = os.environ.copy()
1965+ proxy_settings = charmhelpers.core.hookenv.env_proxy_settings()
1966+ if proxy_settings:
1967+ env.update(proxy_settings)
1968 env['PYTHONUNBUFFERED'] = "1"
1969 call = [
1970 'ansible-playbook',
1971@@ -156,8 +212,7 @@
1972 if tags:
1973 call.extend(['--tags', '{}'.format(tags)])
1974 if extra_vars:
1975- extra = ["%s=%s" % (k, v) for k, v in extra_vars.items()]
1976- call.extend(['--extra-vars', " ".join(extra)])
1977+ call.extend(['--extra-vars', json.dumps(extra_vars)])
1978 subprocess.check_call(call, env=env)
1979
1980
1981@@ -170,7 +225,7 @@
1982
1983 Example::
1984
1985- hooks = AnsibleHooks(playbook_path='playbooks/my_machine_state.yaml')
1986+ hooks = AnsibleHooks(playbook_path='ansible/my_machine_state.yaml')
1987
1988 # All the tasks within my_machine_state.yaml tagged with 'install'
1989 # will be run automatically after do_custom_work()
1990@@ -188,13 +243,12 @@
1991 # the hooks which are handled by ansible-only and they'll be registered
1992 # for you:
1993 # hooks = AnsibleHooks(
1994- # 'playbooks/my_machine_state.yaml',
1995+ # 'ansible/my_machine_state.yaml',
1996 # default_hooks=['config-changed', 'start', 'stop'])
1997
1998 if __name__ == "__main__":
1999 # execute a hook based on the name the program is called by
2000 hooks.execute(sys.argv)
2001-
2002 """
2003
2004 def __init__(self, playbook_path, default_hooks=None):
2005
2006=== added directory 'hooks/charmhelpers/contrib/benchmark'
2007=== added file 'hooks/charmhelpers/contrib/benchmark/__init__.py'
2008--- hooks/charmhelpers/contrib/benchmark/__init__.py 1970-01-01 00:00:00 +0000
2009+++ hooks/charmhelpers/contrib/benchmark/__init__.py 2021-05-12 04:07:51 +0000
2010@@ -0,0 +1,124 @@
2011+# Copyright 2014-2015 Canonical Limited.
2012+#
2013+# Licensed under the Apache License, Version 2.0 (the "License");
2014+# you may not use this file except in compliance with the License.
2015+# You may obtain a copy of the License at
2016+#
2017+# http://www.apache.org/licenses/LICENSE-2.0
2018+#
2019+# Unless required by applicable law or agreed to in writing, software
2020+# distributed under the License is distributed on an "AS IS" BASIS,
2021+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2022+# See the License for the specific language governing permissions and
2023+# limitations under the License.
2024+
2025+import subprocess
2026+import time
2027+import os
2028+from distutils.spawn import find_executable
2029+
2030+from charmhelpers.core.hookenv import (
2031+ in_relation_hook,
2032+ relation_ids,
2033+ relation_set,
2034+ relation_get,
2035+)
2036+
2037+
2038+def action_set(key, val):
2039+ if find_executable('action-set'):
2040+ action_cmd = ['action-set']
2041+
2042+ if isinstance(val, dict):
2043+ for k, v in iter(val.items()):
2044+ action_set('%s.%s' % (key, k), v)
2045+ return True
2046+
2047+ action_cmd.append('%s=%s' % (key, val))
2048+ subprocess.check_call(action_cmd)
2049+ return True
2050+ return False
2051+
2052+
2053+class Benchmark():
2054+ """
2055+ Helper class for the `benchmark` interface.
2056+
2057+ :param list actions: Define the actions that are also benchmarks
2058+
2059+ From inside the benchmark-relation-changed hook, you would
2060+ Benchmark(['memory', 'cpu', 'disk', 'smoke', 'custom'])
2061+
2062+ Examples:
2063+
2064+ siege = Benchmark(['siege'])
2065+ siege.start()
2066+ [... run siege ...]
2067+ # The higher the score, the better the benchmark
2068+ siege.set_composite_score(16.70, 'trans/sec', 'desc')
2069+ siege.finish()
2070+
2071+
2072+ """
2073+
2074+ BENCHMARK_CONF = '/etc/benchmark.conf' # Replaced in testing
2075+
2076+ required_keys = [
2077+ 'hostname',
2078+ 'port',
2079+ 'graphite_port',
2080+ 'graphite_endpoint',
2081+ 'api_port'
2082+ ]
2083+
2084+ def __init__(self, benchmarks=None):
2085+ if in_relation_hook():
2086+ if benchmarks is not None:
2087+ for rid in sorted(relation_ids('benchmark')):
2088+ relation_set(relation_id=rid, relation_settings={
2089+ 'benchmarks': ",".join(benchmarks)
2090+ })
2091+
2092+ # Check the relation data
2093+ config = {}
2094+ for key in self.required_keys:
2095+ val = relation_get(key)
2096+ if val is not None:
2097+ config[key] = val
2098+ else:
2099+ # We don't have all of the required keys
2100+ config = {}
2101+ break
2102+
2103+ if len(config):
2104+ with open(self.BENCHMARK_CONF, 'w') as f:
2105+ for key, val in iter(config.items()):
2106+ f.write("%s=%s\n" % (key, val))
2107+
2108+ @staticmethod
2109+ def start():
2110+ action_set('meta.start', time.strftime('%Y-%m-%dT%H:%M:%SZ'))
2111+
2112+ """
2113+ If the collectd charm is also installed, tell it to send a snapshot
2114+ of the current profile data.
2115+ """
2116+ COLLECT_PROFILE_DATA = '/usr/local/bin/collect-profile-data'
2117+ if os.path.exists(COLLECT_PROFILE_DATA):
2118+ subprocess.check_output([COLLECT_PROFILE_DATA])
2119+
2120+ @staticmethod
2121+ def finish():
2122+ action_set('meta.stop', time.strftime('%Y-%m-%dT%H:%M:%SZ'))
2123+
2124+ @staticmethod
2125+ def set_composite_score(value, units, direction='asc'):
2126+ """
2127+ Set the composite score for a benchmark run. This is a single number
2128+ representative of the benchmark results. This could be the most
2129+ important metric, or an amalgamation of metric scores.
2130+ """
2131+ return action_set(
2132+ "meta.composite",
2133+ {'value': value, 'units': units, 'direction': direction}
2134+ )
2135
2136=== added directory 'hooks/charmhelpers/contrib/charmhelpers'
2137=== added file 'hooks/charmhelpers/contrib/charmhelpers/IMPORT'
2138--- hooks/charmhelpers/contrib/charmhelpers/IMPORT 1970-01-01 00:00:00 +0000
2139+++ hooks/charmhelpers/contrib/charmhelpers/IMPORT 2021-05-12 04:07:51 +0000
2140@@ -0,0 +1,4 @@
2141+Source lp:charm-tools/trunk
2142+
2143+charm-tools/helpers/python/charmhelpers/__init__.py -> charmhelpers/charmhelpers/contrib/charmhelpers/__init__.py
2144+charm-tools/helpers/python/charmhelpers/tests/test_charmhelpers.py -> charmhelpers/tests/contrib/charmhelpers/test_charmhelpers.py
2145
2146=== added file 'hooks/charmhelpers/contrib/charmhelpers/__init__.py'
2147--- hooks/charmhelpers/contrib/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
2148+++ hooks/charmhelpers/contrib/charmhelpers/__init__.py 2021-05-12 04:07:51 +0000
2149@@ -0,0 +1,203 @@
2150+# Copyright 2014-2015 Canonical Limited.
2151+#
2152+# Licensed under the Apache License, Version 2.0 (the "License");
2153+# you may not use this file except in compliance with the License.
2154+# You may obtain a copy of the License at
2155+#
2156+# http://www.apache.org/licenses/LICENSE-2.0
2157+#
2158+# Unless required by applicable law or agreed to in writing, software
2159+# distributed under the License is distributed on an "AS IS" BASIS,
2160+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2161+# See the License for the specific language governing permissions and
2162+# limitations under the License.
2163+
2164+import warnings
2165+warnings.warn("contrib.charmhelpers is deprecated", DeprecationWarning) # noqa
2166+
2167+import operator
2168+import tempfile
2169+import time
2170+import yaml
2171+import subprocess
2172+
2173+import six
2174+if six.PY3:
2175+ from urllib.request import urlopen
2176+ from urllib.error import (HTTPError, URLError)
2177+else:
2178+ from urllib2 import (urlopen, HTTPError, URLError)
2179+
2180+"""Helper functions for writing Juju charms in Python."""
2181+
2182+__metaclass__ = type
2183+__all__ = [
2184+ # 'get_config', # core.hookenv.config()
2185+ # 'log', # core.hookenv.log()
2186+ # 'log_entry', # core.hookenv.log()
2187+ # 'log_exit', # core.hookenv.log()
2188+ # 'relation_get', # core.hookenv.relation_get()
2189+ # 'relation_set', # core.hookenv.relation_set()
2190+ # 'relation_ids', # core.hookenv.relation_ids()
2191+ # 'relation_list', # core.hookenv.relation_units()
2192+ # 'config_get', # core.hookenv.config()
2193+ # 'unit_get', # core.hookenv.unit_get()
2194+ # 'open_port', # core.hookenv.open_port()
2195+ # 'close_port', # core.hookenv.close_port()
2196+ # 'service_control', # core.host.service()
2197+ 'unit_info', # client-side, NOT IMPLEMENTED
2198+ 'wait_for_machine', # client-side, NOT IMPLEMENTED
2199+ 'wait_for_page_contents', # client-side, NOT IMPLEMENTED
2200+ 'wait_for_relation', # client-side, NOT IMPLEMENTED
2201+ 'wait_for_unit', # client-side, NOT IMPLEMENTED
2202+]
2203+
2204+
2205+SLEEP_AMOUNT = 0.1
2206+
2207+
2208+# We create a juju_status Command here because it makes testing much,
2209+# much easier.
2210+def juju_status():
2211+ subprocess.check_call(['juju', 'status'])
2212+
2213+# re-implemented as charmhelpers.fetch.configure_sources()
2214+# def configure_source(update=False):
2215+# source = config_get('source')
2216+# if ((source.startswith('ppa:') or
2217+# source.startswith('cloud:') or
2218+# source.startswith('http:'))):
2219+# run('add-apt-repository', source)
2220+# if source.startswith("http:"):
2221+# run('apt-key', 'import', config_get('key'))
2222+# if update:
2223+# run('apt-get', 'update')
2224+
2225+
2226+# DEPRECATED: client-side only
2227+def make_charm_config_file(charm_config):
2228+ charm_config_file = tempfile.NamedTemporaryFile(mode='w+')
2229+ charm_config_file.write(yaml.dump(charm_config))
2230+ charm_config_file.flush()
2231+ # The NamedTemporaryFile instance is returned instead of just the name
2232+ # because we want to take advantage of garbage collection-triggered
2233+ # deletion of the temp file when it goes out of scope in the caller.
2234+ return charm_config_file
2235+
2236+
2237+# DEPRECATED: client-side only
2238+def unit_info(service_name, item_name, data=None, unit=None):
2239+ if data is None:
2240+ data = yaml.safe_load(juju_status())
2241+ service = data['services'].get(service_name)
2242+ if service is None:
2243+ # XXX 2012-02-08 gmb:
2244+ # This allows us to cope with the race condition that we
2245+ # have between deploying a service and having it come up in
2246+ # `juju status`. We could probably do with cleaning it up so
2247+ # that it fails a bit more noisily after a while.
2248+ return ''
2249+ units = service['units']
2250+ if unit is not None:
2251+ item = units[unit][item_name]
2252+ else:
2253+ # It might seem odd to sort the units here, but we do it to
2254+ # ensure that when no unit is specified, the first unit for the
2255+ # service (or at least the one with the lowest number) is the
2256+ # one whose data gets returned.
2257+ sorted_unit_names = sorted(units.keys())
2258+ item = units[sorted_unit_names[0]][item_name]
2259+ return item
2260+
2261+
2262+# DEPRECATED: client-side only
2263+def get_machine_data():
2264+ return yaml.safe_load(juju_status())['machines']
2265+
2266+
2267+# DEPRECATED: client-side only
2268+def wait_for_machine(num_machines=1, timeout=300):
2269+ """Wait `timeout` seconds for `num_machines` machines to come up.
2270+
2271+ This wait_for... function can be called by other wait_for functions
2272+ whose timeouts might be too short in situations where only a bare
2273+ Juju setup has been bootstrapped.
2274+
2275+ :return: A tuple of (num_machines, time_taken). This is used for
2276+ testing.
2277+ """
2278+ # You may think this is a hack, and you'd be right. The easiest way
2279+ # to tell what environment we're working in (LXC vs EC2) is to check
2280+ # the dns-name of the first machine. If it's localhost we're in LXC
2281+ # and we can just return here.
2282+ if get_machine_data()[0]['dns-name'] == 'localhost':
2283+ return 1, 0
2284+ start_time = time.time()
2285+ while True:
2286+ # Drop the first machine, since it's the Zookeeper and that's
2287+ # not a machine that we need to wait for. This will only work
2288+ # for EC2 environments, which is why we return early above if
2289+ # we're in LXC.
2290+ machine_data = get_machine_data()
2291+ non_zookeeper_machines = [
2292+ machine_data[key] for key in list(machine_data.keys())[1:]]
2293+ if len(non_zookeeper_machines) >= num_machines:
2294+ all_machines_running = True
2295+ for machine in non_zookeeper_machines:
2296+ if machine.get('instance-state') != 'running':
2297+ all_machines_running = False
2298+ break
2299+ if all_machines_running:
2300+ break
2301+ if time.time() - start_time >= timeout:
2302+ raise RuntimeError('timeout waiting for service to start')
2303+ time.sleep(SLEEP_AMOUNT)
2304+ return num_machines, time.time() - start_time
2305+
2306+
2307+# DEPRECATED: client-side only
2308+def wait_for_unit(service_name, timeout=480):
2309+ """Wait `timeout` seconds for a given service name to come up."""
2310+ wait_for_machine(num_machines=1)
2311+ start_time = time.time()
2312+ while True:
2313+ state = unit_info(service_name, 'agent-state')
2314+ if 'error' in state or state == 'started':
2315+ break
2316+ if time.time() - start_time >= timeout:
2317+ raise RuntimeError('timeout waiting for service to start')
2318+ time.sleep(SLEEP_AMOUNT)
2319+ if state != 'started':
2320+ raise RuntimeError('unit did not start, agent-state: ' + state)
2321+
2322+
2323+# DEPRECATED: client-side only
2324+def wait_for_relation(service_name, relation_name, timeout=120):
2325+ """Wait `timeout` seconds for a given relation to come up."""
2326+ start_time = time.time()
2327+ while True:
2328+ relation = unit_info(service_name, 'relations').get(relation_name)
2329+ if relation is not None and relation['state'] == 'up':
2330+ break
2331+ if time.time() - start_time >= timeout:
2332+ raise RuntimeError('timeout waiting for relation to be up')
2333+ time.sleep(SLEEP_AMOUNT)
2334+
2335+
2336+# DEPRECATED: client-side only
2337+def wait_for_page_contents(url, contents, timeout=120, validate=None):
2338+ if validate is None:
2339+ validate = operator.contains
2340+ start_time = time.time()
2341+ while True:
2342+ try:
2343+ stream = urlopen(url)
2344+ except (HTTPError, URLError):
2345+ pass
2346+ else:
2347+ page = stream.read()
2348+ if validate(page, contents):
2349+ return page
2350+ if time.time() - start_time >= timeout:
2351+ raise RuntimeError('timeout waiting for contents of ' + url)
2352+ time.sleep(SLEEP_AMOUNT)
2353
2354=== added file 'hooks/charmhelpers/contrib/charmsupport/IMPORT'
2355--- hooks/charmhelpers/contrib/charmsupport/IMPORT 1970-01-01 00:00:00 +0000
2356+++ hooks/charmhelpers/contrib/charmsupport/IMPORT 2021-05-12 04:07:51 +0000
2357@@ -0,0 +1,14 @@
2358+Source: lp:charmsupport/trunk
2359+
2360+charmsupport/charmsupport/execd.py -> charm-helpers/charmhelpers/contrib/charmsupport/execd.py
2361+charmsupport/charmsupport/hookenv.py -> charm-helpers/charmhelpers/contrib/charmsupport/hookenv.py
2362+charmsupport/charmsupport/host.py -> charm-helpers/charmhelpers/contrib/charmsupport/host.py
2363+charmsupport/charmsupport/nrpe.py -> charm-helpers/charmhelpers/contrib/charmsupport/nrpe.py
2364+charmsupport/charmsupport/volumes.py -> charm-helpers/charmhelpers/contrib/charmsupport/volumes.py
2365+
2366+charmsupport/tests/test_execd.py -> charm-helpers/tests/contrib/charmsupport/test_execd.py
2367+charmsupport/tests/test_hookenv.py -> charm-helpers/tests/contrib/charmsupport/test_hookenv.py
2368+charmsupport/tests/test_host.py -> charm-helpers/tests/contrib/charmsupport/test_host.py
2369+charmsupport/tests/test_nrpe.py -> charm-helpers/tests/contrib/charmsupport/test_nrpe.py
2370+
2371+charmsupport/bin/charmsupport -> charm-helpers/bin/contrib/charmsupport/charmsupport
2372
2373=== modified file 'hooks/charmhelpers/contrib/charmsupport/nrpe.py'
2374--- hooks/charmhelpers/contrib/charmsupport/nrpe.py 2016-12-20 14:35:00 +0000
2375+++ hooks/charmhelpers/contrib/charmsupport/nrpe.py 2021-05-12 04:07:51 +0000
2376@@ -18,20 +18,22 @@
2377 # Authors:
2378 # Matthew Wedgwood <matthew.wedgwood@canonical.com>
2379
2380-import subprocess
2381-import pwd
2382+import glob
2383 import grp
2384 import os
2385-import glob
2386-import shutil
2387+import pwd
2388 import re
2389 import shlex
2390+import shutil
2391+import subprocess
2392 import yaml
2393
2394 from charmhelpers.core.hookenv import (
2395 config,
2396+ hook_name,
2397 local_unit,
2398 log,
2399+ relation_get,
2400 relation_ids,
2401 relation_set,
2402 relations_of_type,
2403@@ -125,7 +127,7 @@
2404
2405
2406 class Check(object):
2407- shortname_re = '[A-Za-z0-9-_]+$'
2408+ shortname_re = '[A-Za-z0-9-_.@]+$'
2409 service_template = ("""
2410 #---------------------------------------------------
2411 # This file is Juju managed
2412@@ -137,10 +139,11 @@
2413 """{description}
2414 check_command check_nrpe!{command}
2415 servicegroups {nagios_servicegroup}
2416+{service_config_overrides}
2417 }}
2418 """)
2419
2420- def __init__(self, shortname, description, check_cmd):
2421+ def __init__(self, shortname, description, check_cmd, max_check_attempts=None):
2422 super(Check, self).__init__()
2423 # XXX: could be better to calculate this from the service name
2424 if not re.match(self.shortname_re, shortname):
2425@@ -153,6 +156,7 @@
2426 # The default is: illegal_object_name_chars=`~!$%^&*"|'<>?,()=
2427 self.description = description
2428 self.check_cmd = self._locate_cmd(check_cmd)
2429+ self.max_check_attempts = max_check_attempts
2430
2431 def _get_check_filename(self):
2432 return os.path.join(NRPE.nrpe_confdir, '{}.cfg'.format(self.command))
2433@@ -193,6 +197,13 @@
2434 nrpe_check_file = self._get_check_filename()
2435 with open(nrpe_check_file, 'w') as nrpe_check_config:
2436 nrpe_check_config.write("# check {}\n".format(self.shortname))
2437+ if nagios_servicegroups:
2438+ nrpe_check_config.write(
2439+ "# The following header was added automatically by juju\n")
2440+ nrpe_check_config.write(
2441+ "# Modifying it will affect nagios monitoring and alerting\n")
2442+ nrpe_check_config.write(
2443+ "# servicegroups: {}\n".format(nagios_servicegroups))
2444 nrpe_check_config.write("command[{}]={}\n".format(
2445 self.command, self.check_cmd))
2446
2447@@ -207,12 +218,19 @@
2448 nagios_servicegroups):
2449 self._remove_service_files()
2450
2451+ if self.max_check_attempts:
2452+ service_config_overrides = ' max_check_attempts {}'.format(
2453+ self.max_check_attempts
2454+ ) # Note indentation is here rather than in the template to avoid trailing spaces
2455+ else:
2456+ service_config_overrides = '' # empty string to avoid printing 'None'
2457 templ_vars = {
2458 'nagios_hostname': hostname,
2459 'nagios_servicegroup': nagios_servicegroups,
2460 'description': self.description,
2461 'shortname': self.shortname,
2462 'command': self.command,
2463+ 'service_config_overrides': service_config_overrides,
2464 }
2465 nrpe_service_text = Check.service_template.format(**templ_vars)
2466 nrpe_service_file = self._get_service_filename(hostname)
2467@@ -227,6 +245,7 @@
2468 nagios_logdir = '/var/log/nagios'
2469 nagios_exportdir = '/var/lib/nagios/export'
2470 nrpe_confdir = '/etc/nagios/nrpe.d'
2471+ homedir = '/var/lib/nagios' # home dir provided by nagios-nrpe-server
2472
2473 def __init__(self, hostname=None, primary=True):
2474 super(NRPE, self).__init__()
2475@@ -251,11 +270,28 @@
2476 relation = relation_ids('nrpe-external-master')
2477 if relation:
2478 log("Setting charm primary status {}".format(primary))
2479- for rid in relation_ids('nrpe-external-master'):
2480+ for rid in relation:
2481 relation_set(relation_id=rid, relation_settings={'primary': self.primary})
2482+ self.remove_check_queue = set()
2483+
2484+ @classmethod
2485+ def does_nrpe_conf_dir_exist(cls):
2486+ """Return True if th nrpe_confdif directory exists."""
2487+ return os.path.isdir(cls.nrpe_confdir)
2488
2489 def add_check(self, *args, **kwargs):
2490+ shortname = None
2491+ if kwargs.get('shortname') is None:
2492+ if len(args) > 0:
2493+ shortname = args[0]
2494+ else:
2495+ shortname = kwargs['shortname']
2496+
2497 self.checks.append(Check(*args, **kwargs))
2498+ try:
2499+ self.remove_check_queue.remove(shortname)
2500+ except KeyError:
2501+ pass
2502
2503 def remove_check(self, *args, **kwargs):
2504 if kwargs.get('shortname') is None:
2505@@ -272,12 +308,13 @@
2506
2507 check = Check(*args, **kwargs)
2508 check.remove(self.hostname)
2509+ self.remove_check_queue.add(kwargs['shortname'])
2510
2511 def write(self):
2512 try:
2513 nagios_uid = pwd.getpwnam('nagios').pw_uid
2514 nagios_gid = grp.getgrnam('nagios').gr_gid
2515- except:
2516+ except Exception:
2517 log("Nagios user not set up, nrpe checks not updated")
2518 return
2519
2520@@ -287,19 +324,50 @@
2521
2522 nrpe_monitors = {}
2523 monitors = {"monitors": {"remote": {"nrpe": nrpe_monitors}}}
2524+
2525+ # check that the charm can write to the conf dir. If not, then nagios
2526+ # probably isn't installed, and we can defer.
2527+ if not self.does_nrpe_conf_dir_exist():
2528+ return
2529+
2530 for nrpecheck in self.checks:
2531 nrpecheck.write(self.nagios_context, self.hostname,
2532 self.nagios_servicegroups)
2533 nrpe_monitors[nrpecheck.shortname] = {
2534 "command": nrpecheck.command,
2535 }
2536+ # If we were passed max_check_attempts, add that to the relation data
2537+ if nrpecheck.max_check_attempts is not None:
2538+ nrpe_monitors[nrpecheck.shortname]['max_check_attempts'] = nrpecheck.max_check_attempts
2539
2540- service('restart', 'nagios-nrpe-server')
2541+ # update-status hooks are configured to firing every 5 minutes by
2542+ # default. When nagios-nrpe-server is restarted, the nagios server
2543+ # reports checks failing causing unnecessary alerts. Let's not restart
2544+ # on update-status hooks.
2545+ if not hook_name() == 'update-status':
2546+ service('restart', 'nagios-nrpe-server')
2547
2548 monitor_ids = relation_ids("local-monitors") + \
2549 relation_ids("nrpe-external-master")
2550 for rid in monitor_ids:
2551- relation_set(relation_id=rid, monitors=yaml.dump(monitors))
2552+ reldata = relation_get(unit=local_unit(), rid=rid)
2553+ if 'monitors' in reldata:
2554+ # update the existing set of monitors with the new data
2555+ old_monitors = yaml.safe_load(reldata['monitors'])
2556+ old_nrpe_monitors = old_monitors['monitors']['remote']['nrpe']
2557+ # remove keys that are in the remove_check_queue
2558+ old_nrpe_monitors = {k: v for k, v in old_nrpe_monitors.items()
2559+ if k not in self.remove_check_queue}
2560+ # update/add nrpe_monitors
2561+ old_nrpe_monitors.update(nrpe_monitors)
2562+ old_monitors['monitors']['remote']['nrpe'] = old_nrpe_monitors
2563+ # write back to the relation
2564+ relation_set(relation_id=rid, monitors=yaml.dump(old_monitors))
2565+ else:
2566+ # write a brand new set of monitors, as no existing ones.
2567+ relation_set(relation_id=rid, monitors=yaml.dump(monitors))
2568+
2569+ self.remove_check_queue.clear()
2570
2571
2572 def get_nagios_hostcontext(relation_name='nrpe-external-master'):
2573@@ -338,13 +406,14 @@
2574 return unit
2575
2576
2577-def add_init_service_checks(nrpe, services, unit_name):
2578+def add_init_service_checks(nrpe, services, unit_name, immediate_check=True):
2579 """
2580 Add checks for each service in list
2581
2582 :param NRPE nrpe: NRPE object to add check to
2583 :param list services: List of services to check
2584 :param str unit_name: Unit name to use in check description
2585+ :param bool immediate_check: For sysv init, run the service check immediately
2586 """
2587 for svc in services:
2588 # Don't add a check for these services from neutron-gateway
2589@@ -354,7 +423,7 @@
2590 upstart_init = '/etc/init/%s.conf' % svc
2591 sysv_init = '/etc/init.d/%s' % svc
2592
2593- if host.init_is_systemd():
2594+ if host.init_is_systemd(service_name=svc):
2595 nrpe.add_check(
2596 shortname=svc,
2597 description='process check {%s}' % unit_name,
2598@@ -368,33 +437,53 @@
2599 )
2600 elif os.path.exists(sysv_init):
2601 cronpath = '/etc/cron.d/nagios-service-check-%s' % svc
2602- cron_file = ('*/5 * * * * root '
2603- '/usr/local/lib/nagios/plugins/check_exit_status.pl '
2604- '-s /etc/init.d/%s status > '
2605- '/var/lib/nagios/service-check-%s.txt\n' % (svc,
2606- svc)
2607- )
2608+ checkpath = '%s/service-check-%s.txt' % (nrpe.homedir, svc)
2609+ croncmd = (
2610+ '/usr/local/lib/nagios/plugins/check_exit_status.pl '
2611+ '-e -s /etc/init.d/%s status' % svc
2612+ )
2613+ cron_file = '*/5 * * * * root %s > %s\n' % (croncmd, checkpath)
2614 f = open(cronpath, 'w')
2615 f.write(cron_file)
2616 f.close()
2617 nrpe.add_check(
2618 shortname=svc,
2619- description='process check {%s}' % unit_name,
2620- check_cmd='check_status_file.py -f '
2621- '/var/lib/nagios/service-check-%s.txt' % svc,
2622+ description='service check {%s}' % unit_name,
2623+ check_cmd='check_status_file.py -f %s' % checkpath,
2624 )
2625-
2626-
2627-def copy_nrpe_checks():
2628+ # if /var/lib/nagios doesn't exist open(checkpath, 'w') will fail
2629+ # (LP: #1670223).
2630+ if immediate_check and os.path.isdir(nrpe.homedir):
2631+ f = open(checkpath, 'w')
2632+ subprocess.call(
2633+ croncmd.split(),
2634+ stdout=f,
2635+ stderr=subprocess.STDOUT
2636+ )
2637+ f.close()
2638+ os.chmod(checkpath, 0o644)
2639+
2640+
2641+def copy_nrpe_checks(nrpe_files_dir=None):
2642 """
2643 Copy the nrpe checks into place
2644
2645 """
2646 NAGIOS_PLUGINS = '/usr/local/lib/nagios/plugins'
2647- nrpe_files_dir = os.path.join(os.getenv('CHARM_DIR'), 'hooks',
2648- 'charmhelpers', 'contrib', 'openstack',
2649- 'files')
2650-
2651+ if nrpe_files_dir is None:
2652+ # determine if "charmhelpers" is in CHARMDIR or CHARMDIR/hooks
2653+ for segment in ['.', 'hooks']:
2654+ nrpe_files_dir = os.path.abspath(os.path.join(
2655+ os.getenv('CHARM_DIR'),
2656+ segment,
2657+ 'charmhelpers',
2658+ 'contrib',
2659+ 'openstack',
2660+ 'files'))
2661+ if os.path.isdir(nrpe_files_dir):
2662+ break
2663+ else:
2664+ raise RuntimeError("Couldn't find charmhelpers directory")
2665 if not os.path.exists(NAGIOS_PLUGINS):
2666 os.makedirs(NAGIOS_PLUGINS)
2667 for fname in glob.glob(os.path.join(nrpe_files_dir, "check_*")):
2668@@ -418,3 +507,17 @@
2669 shortname='haproxy_queue',
2670 description='Check HAProxy queue depth {%s}' % unit_name,
2671 check_cmd='check_haproxy_queue_depth.sh')
2672+
2673+
2674+def remove_deprecated_check(nrpe, deprecated_services):
2675+ """
2676+ Remove checks fro deprecated services in list
2677+
2678+ :param nrpe: NRPE object to remove check from
2679+ :type nrpe: NRPE
2680+ :param deprecated_services: List of deprecated services that are removed
2681+ :type deprecated_services: list
2682+ """
2683+ for dep_svc in deprecated_services:
2684+ log('Deprecated service: {}'.format(dep_svc))
2685+ nrpe.remove_check(shortname=dep_svc)
2686
2687=== added file 'hooks/charmhelpers/contrib/charmsupport/volumes.py'
2688--- hooks/charmhelpers/contrib/charmsupport/volumes.py 1970-01-01 00:00:00 +0000
2689+++ hooks/charmhelpers/contrib/charmsupport/volumes.py 2021-05-12 04:07:51 +0000
2690@@ -0,0 +1,173 @@
2691+# Copyright 2014-2015 Canonical Limited.
2692+#
2693+# Licensed under the Apache License, Version 2.0 (the "License");
2694+# you may not use this file except in compliance with the License.
2695+# You may obtain a copy of the License at
2696+#
2697+# http://www.apache.org/licenses/LICENSE-2.0
2698+#
2699+# Unless required by applicable law or agreed to in writing, software
2700+# distributed under the License is distributed on an "AS IS" BASIS,
2701+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2702+# See the License for the specific language governing permissions and
2703+# limitations under the License.
2704+
2705+'''
2706+Functions for managing volumes in juju units. One volume is supported per unit.
2707+Subordinates may have their own storage, provided it is on its own partition.
2708+
2709+Configuration stanzas::
2710+
2711+ volume-ephemeral:
2712+ type: boolean
2713+ default: true
2714+ description: >
2715+ If false, a volume is mounted as sepecified in "volume-map"
2716+ If true, ephemeral storage will be used, meaning that log data
2717+ will only exist as long as the machine. YOU HAVE BEEN WARNED.
2718+ volume-map:
2719+ type: string
2720+ default: {}
2721+ description: >
2722+ YAML map of units to device names, e.g:
2723+ "{ rsyslog/0: /dev/vdb, rsyslog/1: /dev/vdb }"
2724+ Service units will raise a configure-error if volume-ephemeral
2725+ is 'true' and no volume-map value is set. Use 'juju set' to set a
2726+ value and 'juju resolved' to complete configuration.
2727+
2728+Usage::
2729+
2730+ from charmsupport.volumes import configure_volume, VolumeConfigurationError
2731+ from charmsupport.hookenv import log, ERROR
2732+ def post_mount_hook():
2733+ stop_service('myservice')
2734+ def post_mount_hook():
2735+ start_service('myservice')
2736+
2737+ if __name__ == '__main__':
2738+ try:
2739+ configure_volume(before_change=pre_mount_hook,
2740+ after_change=post_mount_hook)
2741+ except VolumeConfigurationError:
2742+ log('Storage could not be configured', ERROR)
2743+
2744+'''
2745+
2746+# XXX: Known limitations
2747+# - fstab is neither consulted nor updated
2748+
2749+import os
2750+from charmhelpers.core import hookenv
2751+from charmhelpers.core import host
2752+import yaml
2753+
2754+
2755+MOUNT_BASE = '/srv/juju/volumes'
2756+
2757+
2758+class VolumeConfigurationError(Exception):
2759+ '''Volume configuration data is missing or invalid'''
2760+ pass
2761+
2762+
2763+def get_config():
2764+ '''Gather and sanity-check volume configuration data'''
2765+ volume_config = {}
2766+ config = hookenv.config()
2767+
2768+ errors = False
2769+
2770+ if config.get('volume-ephemeral') in (True, 'True', 'true', 'Yes', 'yes'):
2771+ volume_config['ephemeral'] = True
2772+ else:
2773+ volume_config['ephemeral'] = False
2774+
2775+ try:
2776+ volume_map = yaml.safe_load(config.get('volume-map', '{}'))
2777+ except yaml.YAMLError as e:
2778+ hookenv.log("Error parsing YAML volume-map: {}".format(e),
2779+ hookenv.ERROR)
2780+ errors = True
2781+ if volume_map is None:
2782+ # probably an empty string
2783+ volume_map = {}
2784+ elif not isinstance(volume_map, dict):
2785+ hookenv.log("Volume-map should be a dictionary, not {}".format(
2786+ type(volume_map)))
2787+ errors = True
2788+
2789+ volume_config['device'] = volume_map.get(os.environ['JUJU_UNIT_NAME'])
2790+ if volume_config['device'] and volume_config['ephemeral']:
2791+ # asked for ephemeral storage but also defined a volume ID
2792+ hookenv.log('A volume is defined for this unit, but ephemeral '
2793+ 'storage was requested', hookenv.ERROR)
2794+ errors = True
2795+ elif not volume_config['device'] and not volume_config['ephemeral']:
2796+ # asked for permanent storage but did not define volume ID
2797+ hookenv.log('Ephemeral storage was requested, but there is no volume '
2798+ 'defined for this unit.', hookenv.ERROR)
2799+ errors = True
2800+
2801+ unit_mount_name = hookenv.local_unit().replace('/', '-')
2802+ volume_config['mountpoint'] = os.path.join(MOUNT_BASE, unit_mount_name)
2803+
2804+ if errors:
2805+ return None
2806+ return volume_config
2807+
2808+
2809+def mount_volume(config):
2810+ if os.path.exists(config['mountpoint']):
2811+ if not os.path.isdir(config['mountpoint']):
2812+ hookenv.log('Not a directory: {}'.format(config['mountpoint']))
2813+ raise VolumeConfigurationError()
2814+ else:
2815+ host.mkdir(config['mountpoint'])
2816+ if os.path.ismount(config['mountpoint']):
2817+ unmount_volume(config)
2818+ if not host.mount(config['device'], config['mountpoint'], persist=True):
2819+ raise VolumeConfigurationError()
2820+
2821+
2822+def unmount_volume(config):
2823+ if os.path.ismount(config['mountpoint']):
2824+ if not host.umount(config['mountpoint'], persist=True):
2825+ raise VolumeConfigurationError()
2826+
2827+
2828+def managed_mounts():
2829+ '''List of all mounted managed volumes'''
2830+ return filter(lambda mount: mount[0].startswith(MOUNT_BASE), host.mounts())
2831+
2832+
2833+def configure_volume(before_change=lambda: None, after_change=lambda: None):
2834+ '''Set up storage (or don't) according to the charm's volume configuration.
2835+ Returns the mount point or "ephemeral". before_change and after_change
2836+ are optional functions to be called if the volume configuration changes.
2837+ '''
2838+
2839+ config = get_config()
2840+ if not config:
2841+ hookenv.log('Failed to read volume configuration', hookenv.CRITICAL)
2842+ raise VolumeConfigurationError()
2843+
2844+ if config['ephemeral']:
2845+ if os.path.ismount(config['mountpoint']):
2846+ before_change()
2847+ unmount_volume(config)
2848+ after_change()
2849+ return 'ephemeral'
2850+ else:
2851+ # persistent storage
2852+ if os.path.ismount(config['mountpoint']):
2853+ mounts = dict(managed_mounts())
2854+ if mounts.get(config['mountpoint']) != config['device']:
2855+ before_change()
2856+ unmount_volume(config)
2857+ mount_volume(config)
2858+ after_change()
2859+ else:
2860+ before_change()
2861+ mount_volume(config)
2862+ after_change()
2863+ return config['mountpoint']
2864
2865=== added directory 'hooks/charmhelpers/contrib/database'
2866=== added file 'hooks/charmhelpers/contrib/database/__init__.py'
2867--- hooks/charmhelpers/contrib/database/__init__.py 1970-01-01 00:00:00 +0000
2868+++ hooks/charmhelpers/contrib/database/__init__.py 2021-05-12 04:07:51 +0000
2869@@ -0,0 +1,11 @@
2870+# Licensed under the Apache License, Version 2.0 (the "License");
2871+# you may not use this file except in compliance with the License.
2872+# You may obtain a copy of the License at
2873+#
2874+# http://www.apache.org/licenses/LICENSE-2.0
2875+#
2876+# Unless required by applicable law or agreed to in writing, software
2877+# distributed under the License is distributed on an "AS IS" BASIS,
2878+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2879+# See the License for the specific language governing permissions and
2880+# limitations under the License.
2881
2882=== added file 'hooks/charmhelpers/contrib/database/mysql.py'
2883--- hooks/charmhelpers/contrib/database/mysql.py 1970-01-01 00:00:00 +0000
2884+++ hooks/charmhelpers/contrib/database/mysql.py 2021-05-12 04:07:51 +0000
2885@@ -0,0 +1,840 @@
2886+# Licensed under the Apache License, Version 2.0 (the "License");
2887+# you may not use this file except in compliance with the License.
2888+# You may obtain a copy of the License at
2889+#
2890+# http://www.apache.org/licenses/LICENSE-2.0
2891+#
2892+# Unless required by applicable law or agreed to in writing, software
2893+# distributed under the License is distributed on an "AS IS" BASIS,
2894+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2895+# See the License for the specific language governing permissions and
2896+# limitations under the License.
2897+
2898+"""Helper for working with a MySQL database"""
2899+import collections
2900+import copy
2901+import json
2902+import re
2903+import sys
2904+import platform
2905+import os
2906+import glob
2907+import six
2908+
2909+# from string import upper
2910+
2911+from charmhelpers.core.host import (
2912+ CompareHostReleases,
2913+ lsb_release,
2914+ mkdir,
2915+ pwgen,
2916+ write_file
2917+)
2918+from charmhelpers.core.hookenv import (
2919+ config as config_get,
2920+ relation_get,
2921+ related_units,
2922+ unit_get,
2923+ log,
2924+ DEBUG,
2925+ ERROR,
2926+ INFO,
2927+ WARNING,
2928+ leader_get,
2929+ leader_set,
2930+ is_leader,
2931+)
2932+from charmhelpers.fetch import (
2933+ apt_install,
2934+ apt_update,
2935+ filter_installed_packages,
2936+)
2937+from charmhelpers.contrib.network.ip import get_host_ip
2938+
2939+try:
2940+ import MySQLdb
2941+except ImportError:
2942+ apt_update(fatal=True)
2943+ if six.PY2:
2944+ apt_install(filter_installed_packages(['python-mysqldb']), fatal=True)
2945+ else:
2946+ apt_install(filter_installed_packages(['python3-mysqldb']), fatal=True)
2947+ import MySQLdb
2948+
2949+
2950+class MySQLSetPasswordError(Exception):
2951+ pass
2952+
2953+
2954+class MySQLHelper(object):
2955+
2956+ def __init__(self, rpasswdf_template, upasswdf_template, host='localhost',
2957+ migrate_passwd_to_leader_storage=True,
2958+ delete_ondisk_passwd_file=True, user="root", password=None,
2959+ port=None, connect_timeout=None):
2960+ self.user = user
2961+ self.host = host
2962+ self.password = password
2963+ self.port = port
2964+ # default timeout of 30 seconds.
2965+ self.connect_timeout = connect_timeout or 30
2966+
2967+ # Password file path templates
2968+ self.root_passwd_file_template = rpasswdf_template
2969+ self.user_passwd_file_template = upasswdf_template
2970+
2971+ self.migrate_passwd_to_leader_storage = migrate_passwd_to_leader_storage
2972+ # If we migrate we have the option to delete local copy of root passwd
2973+ self.delete_ondisk_passwd_file = delete_ondisk_passwd_file
2974+ self.connection = None
2975+
2976+ def connect(self, user='root', password=None, host=None, port=None,
2977+ connect_timeout=None):
2978+ _connection_info = {
2979+ "user": user or self.user,
2980+ "passwd": password or self.password,
2981+ "host": host or self.host
2982+ }
2983+ # set the connection timeout; for mysql8 it can hang forever, so some
2984+ # timeout is required.
2985+ timeout = connect_timeout or self.connect_timeout
2986+ if timeout:
2987+ _connection_info["connect_timeout"] = timeout
2988+ # port cannot be None but we also do not want to specify it unless it
2989+ # has been explicit set.
2990+ port = port or self.port
2991+ if port is not None:
2992+ _connection_info["port"] = port
2993+
2994+ log("Opening db connection for %s@%s" % (user, host), level=DEBUG)
2995+ try:
2996+ self.connection = MySQLdb.connect(**_connection_info)
2997+ except Exception as e:
2998+ log("Failed to connect to database due to '{}'".format(str(e)),
2999+ level=ERROR)
3000+ raise
3001+
3002+ def database_exists(self, db_name):
3003+ cursor = self.connection.cursor()
3004+ try:
3005+ cursor.execute("SHOW DATABASES")
3006+ databases = [i[0] for i in cursor.fetchall()]
3007+ finally:
3008+ cursor.close()
3009+
3010+ return db_name in databases
3011+
3012+ def create_database(self, db_name):
3013+ cursor = self.connection.cursor()
3014+ try:
3015+ cursor.execute("CREATE DATABASE `{}` CHARACTER SET UTF8"
3016+ .format(db_name))
3017+ finally:
3018+ cursor.close()
3019+
3020+ def grant_exists(self, db_name, db_user, remote_ip):
3021+ cursor = self.connection.cursor()
3022+ priv_string = "GRANT ALL PRIVILEGES ON `{}`.* " \
3023+ "TO '{}'@'{}'".format(db_name, db_user, remote_ip)
3024+ try:
3025+ cursor.execute("SHOW GRANTS for '{}'@'{}'".format(db_user,
3026+ remote_ip))
3027+ grants = [i[0] for i in cursor.fetchall()]
3028+ except MySQLdb.OperationalError:
3029+ return False
3030+ finally:
3031+ cursor.close()
3032+
3033+ # TODO: review for different grants
3034+ return priv_string in grants
3035+
3036+ def create_grant(self, db_name, db_user, remote_ip, password):
3037+ cursor = self.connection.cursor()
3038+ try:
3039+ # TODO: review for different grants
3040+ cursor.execute("GRANT ALL PRIVILEGES ON `{}`.* TO '{}'@'{}' "
3041+ "IDENTIFIED BY '{}'".format(db_name,
3042+ db_user,
3043+ remote_ip,
3044+ password))
3045+ finally:
3046+ cursor.close()
3047+
3048+ def create_admin_grant(self, db_user, remote_ip, password):
3049+ cursor = self.connection.cursor()
3050+ try:
3051+ cursor.execute("GRANT ALL PRIVILEGES ON *.* TO '{}'@'{}' "
3052+ "IDENTIFIED BY '{}'".format(db_user,
3053+ remote_ip,
3054+ password))
3055+ finally:
3056+ cursor.close()
3057+
3058+ def cleanup_grant(self, db_user, remote_ip):
3059+ cursor = self.connection.cursor()
3060+ try:
3061+ cursor.execute("DROP FROM mysql.user WHERE user='{}' "
3062+ "AND HOST='{}'".format(db_user,
3063+ remote_ip))
3064+ finally:
3065+ cursor.close()
3066+
3067+ def flush_priviledges(self):
3068+ cursor = self.connection.cursor()
3069+ try:
3070+ cursor.execute("FLUSH PRIVILEGES")
3071+ finally:
3072+ cursor.close()
3073+
3074+ def execute(self, sql):
3075+ """Execute arbitary SQL against the database."""
3076+ cursor = self.connection.cursor()
3077+ try:
3078+ cursor.execute(sql)
3079+ finally:
3080+ cursor.close()
3081+
3082+ def select(self, sql):
3083+ """
3084+ Execute arbitrary SQL select query against the database
3085+ and return the results.
3086+
3087+ :param sql: SQL select query to execute
3088+ :type sql: string
3089+ :returns: SQL select query result
3090+ :rtype: list of lists
3091+ :raises: MySQLdb.Error
3092+ """
3093+ cursor = self.connection.cursor()
3094+ try:
3095+ cursor.execute(sql)
3096+ results = [list(i) for i in cursor.fetchall()]
3097+ finally:
3098+ cursor.close()
3099+ return results
3100+
3101+ def migrate_passwords_to_leader_storage(self, excludes=None):
3102+ """Migrate any passwords storage on disk to leader storage."""
3103+ if not is_leader():
3104+ log("Skipping password migration as not the lead unit",
3105+ level=DEBUG)
3106+ return
3107+ dirname = os.path.dirname(self.root_passwd_file_template)
3108+ path = os.path.join(dirname, '*.passwd')
3109+ for f in glob.glob(path):
3110+ if excludes and f in excludes:
3111+ log("Excluding %s from leader storage migration" % (f),
3112+ level=DEBUG)
3113+ continue
3114+
3115+ key = os.path.basename(f)
3116+ with open(f, 'r') as passwd:
3117+ _value = passwd.read().strip()
3118+
3119+ try:
3120+ leader_set(settings={key: _value})
3121+
3122+ if self.delete_ondisk_passwd_file:
3123+ os.unlink(f)
3124+ except ValueError:
3125+ # NOTE cluster relation not yet ready - skip for now
3126+ pass
3127+
3128+ def get_mysql_password_on_disk(self, username=None, password=None):
3129+ """Retrieve, generate or store a mysql password for the provided
3130+ username on disk."""
3131+ if username:
3132+ template = self.user_passwd_file_template
3133+ passwd_file = template.format(username)
3134+ else:
3135+ passwd_file = self.root_passwd_file_template
3136+
3137+ _password = None
3138+ if os.path.exists(passwd_file):
3139+ log("Using existing password file '%s'" % passwd_file, level=DEBUG)
3140+ with open(passwd_file, 'r') as passwd:
3141+ _password = passwd.read().strip()
3142+ else:
3143+ log("Generating new password file '%s'" % passwd_file, level=DEBUG)
3144+ if not os.path.isdir(os.path.dirname(passwd_file)):
3145+ # NOTE: need to ensure this is not mysql root dir (which needs
3146+ # to be mysql readable)
3147+ mkdir(os.path.dirname(passwd_file), owner='root', group='root',
3148+ perms=0o770)
3149+ # Force permissions - for some reason the chmod in makedirs
3150+ # fails
3151+ os.chmod(os.path.dirname(passwd_file), 0o770)
3152+
3153+ _password = password or pwgen(length=32)
3154+ write_file(passwd_file, _password, owner='root', group='root',
3155+ perms=0o660)
3156+
3157+ return _password
3158+
3159+ def passwd_keys(self, username):
3160+ """Generator to return keys used to store passwords in peer store.
3161+
3162+ NOTE: we support both legacy and new format to support mysql
3163+ charm prior to refactor. This is necessary to avoid LP 1451890.
3164+ """
3165+ keys = []
3166+ if username == 'mysql':
3167+ log("Bad username '%s'" % (username), level=WARNING)
3168+
3169+ if username:
3170+ # IMPORTANT: *newer* format must be returned first
3171+ keys.append('mysql-%s.passwd' % (username))
3172+ keys.append('%s.passwd' % (username))
3173+ else:
3174+ keys.append('mysql.passwd')
3175+
3176+ for key in keys:
3177+ yield key
3178+
3179+ def get_mysql_password(self, username=None, password=None):
3180+ """Retrieve, generate or store a mysql password for the provided
3181+ username using peer relation cluster."""
3182+ excludes = []
3183+
3184+ # First check peer relation.
3185+ try:
3186+ for key in self.passwd_keys(username):
3187+ _password = leader_get(key)
3188+ if _password:
3189+ break
3190+
3191+ # If root password available don't update peer relation from local
3192+ if _password and not username:
3193+ excludes.append(self.root_passwd_file_template)
3194+
3195+ except ValueError:
3196+ # cluster relation is not yet started; use on-disk
3197+ _password = None
3198+
3199+ # If none available, generate new one
3200+ if not _password:
3201+ _password = self.get_mysql_password_on_disk(username, password)
3202+
3203+ # Put on wire if required
3204+ if self.migrate_passwd_to_leader_storage:
3205+ self.migrate_passwords_to_leader_storage(excludes=excludes)
3206+
3207+ return _password
3208+
3209+ def get_mysql_root_password(self, password=None):
3210+ """Retrieve or generate mysql root password for service units."""
3211+ return self.get_mysql_password(username=None, password=password)
3212+
3213+ def set_mysql_password(self, username, password, current_password=None):
3214+ """Update a mysql password for the provided username changing the
3215+ leader settings
3216+
3217+ To update root's password pass `None` in the username
3218+
3219+ :param username: Username to change password of
3220+ :type username: str
3221+ :param password: New password for user.
3222+ :type password: str
3223+ :param current_password: Existing password for user.
3224+ :type current_password: str
3225+ """
3226+
3227+ if username is None:
3228+ username = 'root'
3229+
3230+ # get root password via leader-get, it may be that in the past (when
3231+ # changes to root-password were not supported) the user changed the
3232+ # password, so leader-get is more reliable source than
3233+ # config.previous('root-password').
3234+ rel_username = None if username == 'root' else username
3235+ if not current_password:
3236+ current_password = self.get_mysql_password(rel_username)
3237+
3238+ # password that needs to be set
3239+ new_passwd = password
3240+
3241+ # update password for all users (e.g. root@localhost, root@::1, etc)
3242+ try:
3243+ self.connect(user=username, password=current_password)
3244+ cursor = self.connection.cursor()
3245+ except MySQLdb.OperationalError as ex:
3246+ raise MySQLSetPasswordError(('Cannot connect using password in '
3247+ 'leader settings (%s)') % ex, ex)
3248+
3249+ try:
3250+ # NOTE(freyes): Due to skip-name-resolve root@$HOSTNAME account
3251+ # fails when using SET PASSWORD so using UPDATE against the
3252+ # mysql.user table is needed, but changes to this table are not
3253+ # replicated across the cluster, so this update needs to run in
3254+ # all the nodes. More info at
3255+ # http://galeracluster.com/documentation-webpages/userchanges.html
3256+ release = CompareHostReleases(lsb_release()['DISTRIB_CODENAME'])
3257+ if release < 'bionic':
3258+ SQL_UPDATE_PASSWD = ("UPDATE mysql.user SET password = "
3259+ "PASSWORD( %s ) WHERE user = %s;")
3260+ else:
3261+ # PXC 5.7 (introduced in Bionic) uses authentication_string
3262+ SQL_UPDATE_PASSWD = ("UPDATE mysql.user SET "
3263+ "authentication_string = "
3264+ "PASSWORD( %s ) WHERE user = %s;")
3265+ cursor.execute(SQL_UPDATE_PASSWD, (new_passwd, username))
3266+ cursor.execute('FLUSH PRIVILEGES;')
3267+ self.connection.commit()
3268+ except MySQLdb.OperationalError as ex:
3269+ raise MySQLSetPasswordError('Cannot update password: %s' % str(ex),
3270+ ex)
3271+ finally:
3272+ cursor.close()
3273+
3274+ # check the password was changed
3275+ try:
3276+ self.connect(user=username, password=new_passwd)
3277+ self.execute('select 1;')
3278+ except MySQLdb.OperationalError as ex:
3279+ raise MySQLSetPasswordError(('Cannot connect using new password: '
3280+ '%s') % str(ex), ex)
3281+
3282+ if not is_leader():
3283+ log('Only the leader can set a new password in the relation',
3284+ level=DEBUG)
3285+ return
3286+
3287+ for key in self.passwd_keys(rel_username):
3288+ _password = leader_get(key)
3289+ if _password:
3290+ log('Updating password for %s (%s)' % (key, rel_username),
3291+ level=DEBUG)
3292+ leader_set(settings={key: new_passwd})
3293+
3294+ def set_mysql_root_password(self, password, current_password=None):
3295+ """Update mysql root password changing the leader settings
3296+
3297+ :param password: New password for user.
3298+ :type password: str
3299+ :param current_password: Existing password for user.
3300+ :type current_password: str
3301+ """
3302+ self.set_mysql_password(
3303+ 'root',
3304+ password,
3305+ current_password=current_password)
3306+
3307+ def normalize_address(self, hostname):
3308+ """Ensure that address returned is an IP address (i.e. not fqdn)"""
3309+ if config_get('prefer-ipv6'):
3310+ # TODO: add support for ipv6 dns
3311+ return hostname
3312+
3313+ if hostname != unit_get('private-address'):
3314+ return get_host_ip(hostname, fallback=hostname)
3315+
3316+ # Otherwise assume localhost
3317+ return '127.0.0.1'
3318+
3319+ def get_allowed_units(self, database, username, relation_id=None, prefix=None):
3320+ """Get list of units with access grants for database with username.
3321+
3322+ This is typically used to provide shared-db relations with a list of
3323+ which units have been granted access to the given database.
3324+ """
3325+ if not self.connection:
3326+ self.connect(password=self.get_mysql_root_password())
3327+ allowed_units = set()
3328+ if not prefix:
3329+ prefix = database
3330+ for unit in related_units(relation_id):
3331+ settings = relation_get(rid=relation_id, unit=unit)
3332+ # First check for setting with prefix, then without
3333+ for attr in ["%s_hostname" % (prefix), 'hostname']:
3334+ hosts = settings.get(attr, None)
3335+ if hosts:
3336+ break
3337+
3338+ if hosts:
3339+ # hostname can be json-encoded list of hostnames
3340+ try:
3341+ hosts = json.loads(hosts)
3342+ except ValueError:
3343+ hosts = [hosts]
3344+ else:
3345+ hosts = [settings['private-address']]
3346+
3347+ if hosts:
3348+ for host in hosts:
3349+ host = self.normalize_address(host)
3350+ if self.grant_exists(database, username, host):
3351+ log("Grant exists for host '%s' on db '%s'" %
3352+ (host, database), level=DEBUG)
3353+ if unit not in allowed_units:
3354+ allowed_units.add(unit)
3355+ else:
3356+ log("Grant does NOT exist for host '%s' on db '%s'" %
3357+ (host, database), level=DEBUG)
3358+ else:
3359+ log("No hosts found for grant check", level=INFO)
3360+
3361+ return allowed_units
3362+
3363+ def configure_db(self, hostname, database, username, admin=False):
3364+ """Configure access to database for username from hostname."""
3365+ if not self.connection:
3366+ self.connect(password=self.get_mysql_root_password())
3367+ if not self.database_exists(database):
3368+ self.create_database(database)
3369+
3370+ remote_ip = self.normalize_address(hostname)
3371+ password = self.get_mysql_password(username)
3372+ if not self.grant_exists(database, username, remote_ip):
3373+ if not admin:
3374+ self.create_grant(database, username, remote_ip, password)
3375+ else:
3376+ self.create_admin_grant(username, remote_ip, password)
3377+ self.flush_priviledges()
3378+
3379+ return password
3380+
3381+
3382+# `_singleton_config_helper` stores the instance of the helper class that is
3383+# being used during a hook invocation.
3384+_singleton_config_helper = None
3385+
3386+
3387+def get_mysql_config_helper():
3388+ global _singleton_config_helper
3389+ if _singleton_config_helper is None:
3390+ _singleton_config_helper = MySQLConfigHelper()
3391+ return _singleton_config_helper
3392+
3393+
3394+class MySQLConfigHelper(object):
3395+ """Base configuration helper for MySQL."""
3396+
3397+ # Going for the biggest page size to avoid wasted bytes.
3398+ # InnoDB page size is 16MB
3399+
3400+ DEFAULT_PAGE_SIZE = 16 * 1024 * 1024
3401+ DEFAULT_INNODB_BUFFER_FACTOR = 0.50
3402+ DEFAULT_INNODB_BUFFER_SIZE_MAX = 512 * 1024 * 1024
3403+
3404+ # Validation and lookups for InnoDB configuration
3405+ INNODB_VALID_BUFFERING_VALUES = [
3406+ 'none',
3407+ 'inserts',
3408+ 'deletes',
3409+ 'changes',
3410+ 'purges',
3411+ 'all'
3412+ ]
3413+ INNODB_FLUSH_CONFIG_VALUES = {
3414+ 'fast': 2,
3415+ 'safest': 1,
3416+ 'unsafe': 0,
3417+ }
3418+
3419+ def human_to_bytes(self, human):
3420+ """Convert human readable configuration options to bytes."""
3421+ num_re = re.compile('^[0-9]+$')
3422+ if num_re.match(human):
3423+ return human
3424+
3425+ factors = {
3426+ 'K': 1024,
3427+ 'M': 1048576,
3428+ 'G': 1073741824,
3429+ 'T': 1099511627776
3430+ }
3431+ modifier = human[-1]
3432+ if modifier in factors:
3433+ return int(human[:-1]) * factors[modifier]
3434+
3435+ if modifier == '%':
3436+ total_ram = self.human_to_bytes(self.get_mem_total())
3437+ if self.is_32bit_system() and total_ram > self.sys_mem_limit():
3438+ total_ram = self.sys_mem_limit()
3439+ factor = int(human[:-1]) * 0.01
3440+ pctram = total_ram * factor
3441+ return int(pctram - (pctram % self.DEFAULT_PAGE_SIZE))
3442+
3443+ raise ValueError("Can only convert K,M,G, or T")
3444+
3445+ def is_32bit_system(self):
3446+ """Determine whether system is 32 or 64 bit."""
3447+ try:
3448+ return sys.maxsize < 2 ** 32
3449+ except OverflowError:
3450+ return False
3451+
3452+ def sys_mem_limit(self):
3453+ """Determine the default memory limit for the current service unit."""
3454+ if platform.machine() in ['armv7l']:
3455+ _mem_limit = self.human_to_bytes('2700M') # experimentally determined
3456+ else:
3457+ # Limit for x86 based 32bit systems
3458+ _mem_limit = self.human_to_bytes('4G')
3459+
3460+ return _mem_limit
3461+
3462+ def get_mem_total(self):
3463+ """Calculate the total memory in the current service unit."""
3464+ with open('/proc/meminfo') as meminfo_file:
3465+ for line in meminfo_file:
3466+ key, mem = line.split(':', 2)
3467+ if key == 'MemTotal':
3468+ mtot, modifier = mem.strip().split(' ')
3469+ return '%s%s' % (mtot, modifier[0].upper())
3470+
3471+ def get_innodb_flush_log_at_trx_commit(self):
3472+ """Get value for innodb_flush_log_at_trx_commit.
3473+
3474+ Use the innodb-flush-log-at-trx-commit or the tunning-level setting
3475+ translated by INNODB_FLUSH_CONFIG_VALUES to get the
3476+ innodb_flush_log_at_trx_commit value.
3477+
3478+ :returns: Numeric value for innodb_flush_log_at_trx_commit
3479+ :rtype: Union[None, int]
3480+ """
3481+ _iflatc = config_get('innodb-flush-log-at-trx-commit')
3482+ _tuning_level = config_get('tuning-level')
3483+ if _iflatc:
3484+ return _iflatc
3485+ elif _tuning_level:
3486+ return self.INNODB_FLUSH_CONFIG_VALUES.get(_tuning_level, 1)
3487+
3488+ def get_innodb_change_buffering(self):
3489+ """Get value for innodb_change_buffering.
3490+
3491+ Use the innodb-change-buffering validated against
3492+ INNODB_VALID_BUFFERING_VALUES to get the innodb_change_buffering value.
3493+
3494+ :returns: String value for innodb_change_buffering.
3495+ :rtype: Union[None, str]
3496+ """
3497+ _icb = config_get('innodb-change-buffering')
3498+ if _icb and _icb in self.INNODB_VALID_BUFFERING_VALUES:
3499+ return _icb
3500+
3501+ def get_innodb_buffer_pool_size(self):
3502+ """Get value for innodb_buffer_pool_size.
3503+
3504+ Return the number value of innodb-buffer-pool-size or dataset-size. If
3505+ neither is set, calculate a sane default based on total memory.
3506+
3507+ :returns: Numeric value for innodb_buffer_pool_size.
3508+ :rtype: int
3509+ """
3510+ total_memory = self.human_to_bytes(self.get_mem_total())
3511+
3512+ dataset_bytes = config_get('dataset-size')
3513+ innodb_buffer_pool_size = config_get('innodb-buffer-pool-size')
3514+
3515+ if innodb_buffer_pool_size:
3516+ innodb_buffer_pool_size = self.human_to_bytes(
3517+ innodb_buffer_pool_size)
3518+ elif dataset_bytes:
3519+ log("Option 'dataset-size' has been deprecated, please use"
3520+ "innodb_buffer_pool_size option instead", level="WARN")
3521+ innodb_buffer_pool_size = self.human_to_bytes(
3522+ dataset_bytes)
3523+ else:
3524+ # NOTE(jamespage): pick the smallest of 50% of RAM or 512MB
3525+ # to ensure that deployments in containers
3526+ # without constraints don't try to consume
3527+ # silly amounts of memory.
3528+ innodb_buffer_pool_size = min(
3529+ int(total_memory * self.DEFAULT_INNODB_BUFFER_FACTOR),
3530+ self.DEFAULT_INNODB_BUFFER_SIZE_MAX
3531+ )
3532+
3533+ if innodb_buffer_pool_size > total_memory:
3534+ log("innodb_buffer_pool_size; {} is greater than system available memory:{}".format(
3535+ innodb_buffer_pool_size,
3536+ total_memory), level='WARN')
3537+
3538+ return innodb_buffer_pool_size
3539+
3540+
3541+class PerconaClusterHelper(MySQLConfigHelper):
3542+ """Percona-cluster specific configuration helper."""
3543+
3544+ def parse_config(self):
3545+ """Parse charm configuration and calculate values for config files."""
3546+ config = config_get()
3547+ mysql_config = {}
3548+ if 'max-connections' in config:
3549+ mysql_config['max_connections'] = config['max-connections']
3550+
3551+ if 'wait-timeout' in config:
3552+ mysql_config['wait_timeout'] = config['wait-timeout']
3553+
3554+ if self.get_innodb_flush_log_at_trx_commit() is not None:
3555+ mysql_config['innodb_flush_log_at_trx_commit'] = \
3556+ self.get_innodb_flush_log_at_trx_commit()
3557+
3558+ if self.get_innodb_change_buffering() is not None:
3559+ mysql_config['innodb_change_buffering'] = config['innodb-change-buffering']
3560+
3561+ if 'innodb-io-capacity' in config:
3562+ mysql_config['innodb_io_capacity'] = config['innodb-io-capacity']
3563+
3564+ # Set a sane default key_buffer size
3565+ mysql_config['key_buffer'] = self.human_to_bytes('32M')
3566+ mysql_config['innodb_buffer_pool_size'] = self.get_innodb_buffer_pool_size()
3567+ return mysql_config
3568+
3569+
3570+class MySQL8Helper(MySQLHelper):
3571+
3572+ def grant_exists(self, db_name, db_user, remote_ip):
3573+ cursor = self.connection.cursor()
3574+ priv_string = ("GRANT ALL PRIVILEGES ON {}.* "
3575+ "TO {}@{}".format(db_name, db_user, remote_ip))
3576+ try:
3577+ cursor.execute("SHOW GRANTS FOR '{}'@'{}'".format(db_user,
3578+ remote_ip))
3579+ grants = [i[0] for i in cursor.fetchall()]
3580+ except MySQLdb.OperationalError:
3581+ return False
3582+ finally:
3583+ cursor.close()
3584+
3585+ # Different versions of MySQL use ' or `. Ignore these in the check.
3586+ return priv_string in [
3587+ i.replace("'", "").replace("`", "") for i in grants]
3588+
3589+ def create_grant(self, db_name, db_user, remote_ip, password):
3590+ if self.grant_exists(db_name, db_user, remote_ip):
3591+ return
3592+
3593+ # Make sure the user exists
3594+ # MySQL8 must create the user before the grant
3595+ self.create_user(db_user, remote_ip, password)
3596+
3597+ cursor = self.connection.cursor()
3598+ try:
3599+ cursor.execute("GRANT ALL PRIVILEGES ON `{}`.* TO '{}'@'{}'"
3600+ .format(db_name, db_user, remote_ip))
3601+ finally:
3602+ cursor.close()
3603+
3604+ def create_user(self, db_user, remote_ip, password):
3605+
3606+ SQL_USER_CREATE = (
3607+ "CREATE USER '{db_user}'@'{remote_ip}' "
3608+ "IDENTIFIED BY '{password}'")
3609+
3610+ cursor = self.connection.cursor()
3611+ try:
3612+ cursor.execute(SQL_USER_CREATE.format(
3613+ db_user=db_user,
3614+ remote_ip=remote_ip,
3615+ password=password)
3616+ )
3617+ except MySQLdb._exceptions.OperationalError:
3618+ log("DB user {} already exists.".format(db_user),
3619+ "WARNING")
3620+ finally:
3621+ cursor.close()
3622+
3623+ def create_router_grant(self, db_user, remote_ip, password):
3624+
3625+ # Make sure the user exists
3626+ # MySQL8 must create the user before the grant
3627+ self.create_user(db_user, remote_ip, password)
3628+
3629+ # Mysql-Router specific grants
3630+ cursor = self.connection.cursor()
3631+ try:
3632+ cursor.execute("GRANT CREATE USER ON *.* TO '{}'@'{}' WITH GRANT "
3633+ "OPTION".format(db_user, remote_ip))
3634+ cursor.execute("GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON "
3635+ "mysql_innodb_cluster_metadata.* TO '{}'@'{}'"
3636+ .format(db_user, remote_ip))
3637+ cursor.execute("GRANT SELECT ON mysql.user TO '{}'@'{}'"
3638+ .format(db_user, remote_ip))
3639+ cursor.execute("GRANT SELECT ON "
3640+ "performance_schema.replication_group_members "
3641+ "TO '{}'@'{}'".format(db_user, remote_ip))
3642+ cursor.execute("GRANT SELECT ON "
3643+ "performance_schema.replication_group_member_stats "
3644+ "TO '{}'@'{}'".format(db_user, remote_ip))
3645+ cursor.execute("GRANT SELECT ON "
3646+ "performance_schema.global_variables "
3647+ "TO '{}'@'{}'".format(db_user, remote_ip))
3648+ finally:
3649+ cursor.close()
3650+
3651+ def configure_router(self, hostname, username):
3652+
3653+ if self.connection is None:
3654+ self.connect(password=self.get_mysql_root_password())
3655+
3656+ remote_ip = self.normalize_address(hostname)
3657+ password = self.get_mysql_password(username)
3658+ self.create_user(username, remote_ip, password)
3659+ self.create_router_grant(username, remote_ip, password)
3660+
3661+ return password
3662+
3663+
3664+def get_prefix(requested, keys=None):
3665+ """Return existing prefix or None.
3666+
3667+ :param requested: Request string. i.e. novacell0_username
3668+ :type requested: str
3669+ :param keys: Keys to determine prefix. Defaults set in function.
3670+ :type keys: List of str keys
3671+ :returns: String prefix i.e. novacell0
3672+ :rtype: Union[None, str]
3673+ """
3674+ if keys is None:
3675+ # Shared-DB default keys
3676+ keys = ["_database", "_username", "_hostname"]
3677+ for key in keys:
3678+ if requested.endswith(key):
3679+ return requested[:-len(key)]
3680+
3681+
3682+def get_db_data(relation_data, unprefixed):
3683+ """Organize database requests into a collections.OrderedDict
3684+
3685+ :param relation_data: shared-db relation data
3686+ :type relation_data: dict
3687+ :param unprefixed: Prefix to use for requests without a prefix. This should
3688+ be unique for each side of the relation to avoid
3689+ conflicts.
3690+ :type unprefixed: str
3691+ :returns: Order dict of databases and users
3692+ :rtype: collections.OrderedDict
3693+ """
3694+ # Deep copy to avoid unintentionally changing relation data
3695+ settings = copy.deepcopy(relation_data)
3696+ databases = collections.OrderedDict()
3697+
3698+ # Clear non-db related elements
3699+ if "egress-subnets" in settings.keys():
3700+ settings.pop("egress-subnets")
3701+ if "ingress-address" in settings.keys():
3702+ settings.pop("ingress-address")
3703+ if "private-address" in settings.keys():
3704+ settings.pop("private-address")
3705+
3706+ singleset = {"database", "username", "hostname"}
3707+ if singleset.issubset(settings):
3708+ settings["{}_{}".format(unprefixed, "hostname")] = (
3709+ settings["hostname"])
3710+ settings.pop("hostname")
3711+ settings["{}_{}".format(unprefixed, "database")] = (
3712+ settings["database"])
3713+ settings.pop("database")
3714+ settings["{}_{}".format(unprefixed, "username")] = (
3715+ settings["username"])
3716+ settings.pop("username")
3717+
3718+ for k, v in settings.items():
3719+ db = k.split("_")[0]
3720+ x = "_".join(k.split("_")[1:])
3721+ if db not in databases:
3722+ databases[db] = collections.OrderedDict()
3723+ databases[db][x] = v
3724+
3725+ return databases
3726
3727=== added directory 'hooks/charmhelpers/contrib/hahelpers'
3728=== added file 'hooks/charmhelpers/contrib/hahelpers/__init__.py'
3729--- hooks/charmhelpers/contrib/hahelpers/__init__.py 1970-01-01 00:00:00 +0000
3730+++ hooks/charmhelpers/contrib/hahelpers/__init__.py 2021-05-12 04:07:51 +0000
3731@@ -0,0 +1,13 @@
3732+# Copyright 2014-2015 Canonical Limited.
3733+#
3734+# Licensed under the Apache License, Version 2.0 (the "License");
3735+# you may not use this file except in compliance with the License.
3736+# You may obtain a copy of the License at
3737+#
3738+# http://www.apache.org/licenses/LICENSE-2.0
3739+#
3740+# Unless required by applicable law or agreed to in writing, software
3741+# distributed under the License is distributed on an "AS IS" BASIS,
3742+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3743+# See the License for the specific language governing permissions and
3744+# limitations under the License.
3745
3746=== added file 'hooks/charmhelpers/contrib/hahelpers/apache.py'
3747--- hooks/charmhelpers/contrib/hahelpers/apache.py 1970-01-01 00:00:00 +0000
3748+++ hooks/charmhelpers/contrib/hahelpers/apache.py 2021-05-12 04:07:51 +0000
3749@@ -0,0 +1,90 @@
3750+# Copyright 2014-2015 Canonical Limited.
3751+#
3752+# Licensed under the Apache License, Version 2.0 (the "License");
3753+# you may not use this file except in compliance with the License.
3754+# You may obtain a copy of the License at
3755+#
3756+# http://www.apache.org/licenses/LICENSE-2.0
3757+#
3758+# Unless required by applicable law or agreed to in writing, software
3759+# distributed under the License is distributed on an "AS IS" BASIS,
3760+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3761+# See the License for the specific language governing permissions and
3762+# limitations under the License.
3763+
3764+#
3765+# Copyright 2012 Canonical Ltd.
3766+#
3767+# This file is sourced from lp:openstack-charm-helpers
3768+#
3769+# Authors:
3770+# James Page <james.page@ubuntu.com>
3771+# Adam Gandelman <adamg@ubuntu.com>
3772+#
3773+
3774+import os
3775+
3776+from charmhelpers.core import host
3777+from charmhelpers.core.hookenv import (
3778+ config as config_get,
3779+ relation_get,
3780+ relation_ids,
3781+ related_units as relation_list,
3782+ log,
3783+ INFO,
3784+)
3785+
3786+# This file contains the CA cert from the charms ssl_ca configuration
3787+# option, in future the file name should be updated reflect that.
3788+CONFIG_CA_CERT_FILE = 'keystone_juju_ca_cert'
3789+
3790+
3791+def get_cert(cn=None):
3792+ # TODO: deal with multiple https endpoints via charm config
3793+ cert = config_get('ssl_cert')
3794+ key = config_get('ssl_key')
3795+ if not (cert and key):
3796+ log("Inspecting identity-service relations for SSL certificate.",
3797+ level=INFO)
3798+ cert = key = None
3799+ if cn:
3800+ ssl_cert_attr = 'ssl_cert_{}'.format(cn)
3801+ ssl_key_attr = 'ssl_key_{}'.format(cn)
3802+ else:
3803+ ssl_cert_attr = 'ssl_cert'
3804+ ssl_key_attr = 'ssl_key'
3805+ for r_id in relation_ids('identity-service'):
3806+ for unit in relation_list(r_id):
3807+ if not cert:
3808+ cert = relation_get(ssl_cert_attr,
3809+ rid=r_id, unit=unit)
3810+ if not key:
3811+ key = relation_get(ssl_key_attr,
3812+ rid=r_id, unit=unit)
3813+ return (cert, key)
3814+
3815+
3816+def get_ca_cert():
3817+ ca_cert = config_get('ssl_ca')
3818+ if ca_cert is None:
3819+ log("Inspecting identity-service relations for CA SSL certificate.",
3820+ level=INFO)
3821+ for r_id in (relation_ids('identity-service') +
3822+ relation_ids('identity-credentials')):
3823+ for unit in relation_list(r_id):
3824+ if ca_cert is None:
3825+ ca_cert = relation_get('ca_cert',
3826+ rid=r_id, unit=unit)
3827+ return ca_cert
3828+
3829+
3830+def retrieve_ca_cert(cert_file):
3831+ cert = None
3832+ if os.path.isfile(cert_file):
3833+ with open(cert_file, 'rb') as crt:
3834+ cert = crt.read()
3835+ return cert
3836+
3837+
3838+def install_ca_cert(ca_cert):
3839+ host.install_ca_cert(ca_cert, CONFIG_CA_CERT_FILE)
3840
3841=== added file 'hooks/charmhelpers/contrib/hahelpers/cluster.py'
3842--- hooks/charmhelpers/contrib/hahelpers/cluster.py 1970-01-01 00:00:00 +0000
3843+++ hooks/charmhelpers/contrib/hahelpers/cluster.py 2021-05-12 04:07:51 +0000
3844@@ -0,0 +1,451 @@
3845+# Copyright 2014-2015 Canonical Limited.
3846+#
3847+# Licensed under the Apache License, Version 2.0 (the "License");
3848+# you may not use this file except in compliance with the License.
3849+# You may obtain a copy of the License at
3850+#
3851+# http://www.apache.org/licenses/LICENSE-2.0
3852+#
3853+# Unless required by applicable law or agreed to in writing, software
3854+# distributed under the License is distributed on an "AS IS" BASIS,
3855+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
3856+# See the License for the specific language governing permissions and
3857+# limitations under the License.
3858+
3859+#
3860+# Copyright 2012 Canonical Ltd.
3861+#
3862+# Authors:
3863+# James Page <james.page@ubuntu.com>
3864+# Adam Gandelman <adamg@ubuntu.com>
3865+#
3866+
3867+"""
3868+Helpers for clustering and determining "cluster leadership" and other
3869+clustering-related helpers.
3870+"""
3871+
3872+import functools
3873+import subprocess
3874+import os
3875+import time
3876+
3877+from socket import gethostname as get_unit_hostname
3878+
3879+import six
3880+
3881+from charmhelpers.core.hookenv import (
3882+ log,
3883+ relation_ids,
3884+ related_units as relation_list,
3885+ relation_get,
3886+ config as config_get,
3887+ INFO,
3888+ DEBUG,
3889+ WARNING,
3890+ unit_get,
3891+ is_leader as juju_is_leader,
3892+ status_set,
3893+)
3894+from charmhelpers.core.host import (
3895+ modulo_distribution,
3896+)
3897+from charmhelpers.core.decorators import (
3898+ retry_on_exception,
3899+)
3900+from charmhelpers.core.strutils import (
3901+ bool_from_string,
3902+)
3903+
3904+DC_RESOURCE_NAME = 'DC'
3905+
3906+
3907+class HAIncompleteConfig(Exception):
3908+ pass
3909+
3910+
3911+class HAIncorrectConfig(Exception):
3912+ pass
3913+
3914+
3915+class CRMResourceNotFound(Exception):
3916+ pass
3917+
3918+
3919+class CRMDCNotFound(Exception):
3920+ pass
3921+
3922+
3923+def is_elected_leader(resource):
3924+ """
3925+ Returns True if the charm executing this is the elected cluster leader.
3926+
3927+ It relies on two mechanisms to determine leadership:
3928+ 1. If juju is sufficiently new and leadership election is supported,
3929+ the is_leader command will be used.
3930+ 2. If the charm is part of a corosync cluster, call corosync to
3931+ determine leadership.
3932+ 3. If the charm is not part of a corosync cluster, the leader is
3933+ determined as being "the alive unit with the lowest unit numer". In
3934+ other words, the oldest surviving unit.
3935+ """
3936+ try:
3937+ return juju_is_leader()
3938+ except NotImplementedError:
3939+ log('Juju leadership election feature not enabled'
3940+ ', using fallback support',
3941+ level=WARNING)
3942+
3943+ if is_clustered():
3944+ if not is_crm_leader(resource):
3945+ log('Deferring action to CRM leader.', level=INFO)
3946+ return False
3947+ else:
3948+ peers = peer_units()
3949+ if peers and not oldest_peer(peers):
3950+ log('Deferring action to oldest service unit.', level=INFO)
3951+ return False
3952+ return True
3953+
3954+
3955+def is_clustered():
3956+ for r_id in (relation_ids('ha') or []):
3957+ for unit in (relation_list(r_id) or []):
3958+ clustered = relation_get('clustered',
3959+ rid=r_id,
3960+ unit=unit)
3961+ if clustered:
3962+ return True
3963+ return False
3964+
3965+
3966+def is_crm_dc():
3967+ """
3968+ Determine leadership by querying the pacemaker Designated Controller
3969+ """
3970+ cmd = ['crm', 'status']
3971+ try:
3972+ status = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
3973+ if not isinstance(status, six.text_type):
3974+ status = six.text_type(status, "utf-8")
3975+ except subprocess.CalledProcessError as ex:
3976+ raise CRMDCNotFound(str(ex))
3977+
3978+ current_dc = ''
3979+ for line in status.split('\n'):
3980+ if line.startswith('Current DC'):
3981+ # Current DC: juju-lytrusty-machine-2 (168108163) - partition with quorum
3982+ current_dc = line.split(':')[1].split()[0]
3983+ if current_dc == get_unit_hostname():
3984+ return True
3985+ elif current_dc == 'NONE':
3986+ raise CRMDCNotFound('Current DC: NONE')
3987+
3988+ return False
3989+
3990+
3991+@retry_on_exception(5, base_delay=2,
3992+ exc_type=(CRMResourceNotFound, CRMDCNotFound))
3993+def is_crm_leader(resource, retry=False):
3994+ """
3995+ Returns True if the charm calling this is the elected corosync leader,
3996+ as returned by calling the external "crm" command.
3997+
3998+ We allow this operation to be retried to avoid the possibility of getting a
3999+ false negative. See LP #1396246 for more info.
4000+ """
4001+ if resource == DC_RESOURCE_NAME:
4002+ return is_crm_dc()
4003+ cmd = ['crm', 'resource', 'show', resource]
4004+ try:
4005+ status = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
4006+ if not isinstance(status, six.text_type):
4007+ status = six.text_type(status, "utf-8")
4008+ except subprocess.CalledProcessError:
4009+ status = None
4010+
4011+ if status and get_unit_hostname() in status:
4012+ return True
4013+
4014+ if status and "resource %s is NOT running" % (resource) in status:
4015+ raise CRMResourceNotFound("CRM resource %s not found" % (resource))
4016+
4017+ return False
4018+
4019+
4020+def is_leader(resource):
4021+ log("is_leader is deprecated. Please consider using is_crm_leader "
4022+ "instead.", level=WARNING)
4023+ return is_crm_leader(resource)
4024+
4025+
4026+def peer_units(peer_relation="cluster"):
4027+ peers = []
4028+ for r_id in (relation_ids(peer_relation) or []):
4029+ for unit in (relation_list(r_id) or []):
4030+ peers.append(unit)
4031+ return peers
4032+
4033+
4034+def peer_ips(peer_relation='cluster', addr_key='private-address'):
4035+ '''Return a dict of peers and their private-address'''
4036+ peers = {}
4037+ for r_id in relation_ids(peer_relation):
4038+ for unit in relation_list(r_id):
4039+ peers[unit] = relation_get(addr_key, rid=r_id, unit=unit)
4040+ return peers
4041+
4042+
4043+def oldest_peer(peers):
4044+ """Determines who the oldest peer is by comparing unit numbers."""
4045+ local_unit_no = int(os.getenv('JUJU_UNIT_NAME').split('/')[1])
4046+ for peer in peers:
4047+ remote_unit_no = int(peer.split('/')[1])
4048+ if remote_unit_no < local_unit_no:
4049+ return False
4050+ return True
4051+
4052+
4053+def eligible_leader(resource):
4054+ log("eligible_leader is deprecated. Please consider using "
4055+ "is_elected_leader instead.", level=WARNING)
4056+ return is_elected_leader(resource)
4057+
4058+
4059+def https():
4060+ '''
4061+ Determines whether enough data has been provided in configuration
4062+ or relation data to configure HTTPS
4063+ .
4064+ returns: boolean
4065+ '''
4066+ use_https = config_get('use-https')
4067+ if use_https and bool_from_string(use_https):
4068+ return True
4069+ if config_get('ssl_cert') and config_get('ssl_key'):
4070+ return True
4071+ for r_id in relation_ids('certificates'):
4072+ for unit in relation_list(r_id):
4073+ ca = relation_get('ca', rid=r_id, unit=unit)
4074+ if ca:
4075+ return True
4076+ for r_id in relation_ids('identity-service'):
4077+ for unit in relation_list(r_id):
4078+ # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN
4079+ rel_state = [
4080+ relation_get('https_keystone', rid=r_id, unit=unit),
4081+ relation_get('ca_cert', rid=r_id, unit=unit),
4082+ ]
4083+ # NOTE: works around (LP: #1203241)
4084+ if (None not in rel_state) and ('' not in rel_state):
4085+ return True
4086+ return False
4087+
4088+
4089+def determine_api_port(public_port, singlenode_mode=False):
4090+ '''
4091+ Determine correct API server listening port based on
4092+ existence of HTTPS reverse proxy and/or haproxy.
4093+
4094+ public_port: int: standard public port for given service
4095+
4096+ singlenode_mode: boolean: Shuffle ports when only a single unit is present
4097+
4098+ returns: int: the correct listening port for the API service
4099+ '''
4100+ i = 0
4101+ if singlenode_mode:
4102+ i += 1
4103+ elif len(peer_units()) > 0 or is_clustered():
4104+ i += 1
4105+ if https():
4106+ i += 1
4107+ return public_port - (i * 10)
4108+
4109+
4110+def determine_apache_port(public_port, singlenode_mode=False):
4111+ '''
4112+ Description: Determine correct apache listening port based on public IP +
4113+ state of the cluster.
4114+
4115+ public_port: int: standard public port for given service
4116+
4117+ singlenode_mode: boolean: Shuffle ports when only a single unit is present
4118+
4119+ returns: int: the correct listening port for the HAProxy service
4120+ '''
4121+ i = 0
4122+ if singlenode_mode:
4123+ i += 1
4124+ elif len(peer_units()) > 0 or is_clustered():
4125+ i += 1
4126+ return public_port - (i * 10)
4127+
4128+
4129+determine_apache_port_single = functools.partial(
4130+ determine_apache_port, singlenode_mode=True)
4131+
4132+
4133+def get_hacluster_config(exclude_keys=None):
4134+ '''
4135+ Obtains all relevant configuration from charm configuration required
4136+ for initiating a relation to hacluster:
4137+
4138+ ha-bindiface, ha-mcastport, vip, os-internal-hostname,
4139+ os-admin-hostname, os-public-hostname, os-access-hostname
4140+
4141+ param: exclude_keys: list of setting key(s) to be excluded.
4142+ returns: dict: A dict containing settings keyed by setting name.
4143+ raises: HAIncompleteConfig if settings are missing or incorrect.
4144+ '''
4145+ settings = ['ha-bindiface', 'ha-mcastport', 'vip', 'os-internal-hostname',
4146+ 'os-admin-hostname', 'os-public-hostname', 'os-access-hostname']
4147+ conf = {}
4148+ for setting in settings:
4149+ if exclude_keys and setting in exclude_keys:
4150+ continue
4151+
4152+ conf[setting] = config_get(setting)
4153+
4154+ if not valid_hacluster_config():
4155+ raise HAIncorrectConfig('Insufficient or incorrect config data to '
4156+ 'configure hacluster.')
4157+ return conf
4158+
4159+
4160+def valid_hacluster_config():
4161+ '''
4162+ Check that either vip or dns-ha is set. If dns-ha then one of os-*-hostname
4163+ must be set.
4164+
4165+ Note: ha-bindiface and ha-macastport both have defaults and will always
4166+ be set. We only care that either vip or dns-ha is set.
4167+
4168+ :returns: boolean: valid config returns true.
4169+ raises: HAIncompatibileConfig if settings conflict.
4170+ raises: HAIncompleteConfig if settings are missing.
4171+ '''
4172+ vip = config_get('vip')
4173+ dns = config_get('dns-ha')
4174+ if not(bool(vip) ^ bool(dns)):
4175+ msg = ('HA: Either vip or dns-ha must be set but not both in order to '
4176+ 'use high availability')
4177+ status_set('blocked', msg)
4178+ raise HAIncorrectConfig(msg)
4179+
4180+ # If dns-ha then one of os-*-hostname must be set
4181+ if dns:
4182+ dns_settings = ['os-internal-hostname', 'os-admin-hostname',
4183+ 'os-public-hostname', 'os-access-hostname']
4184+ # At this point it is unknown if one or all of the possible
4185+ # network spaces are in HA. Validate at least one is set which is
4186+ # the minimum required.
4187+ for setting in dns_settings:
4188+ if config_get(setting):
4189+ log('DNS HA: At least one hostname is set {}: {}'
4190+ ''.format(setting, config_get(setting)),
4191+ level=DEBUG)
4192+ return True
4193+
4194+ msg = ('DNS HA: At least one os-*-hostname(s) must be set to use '
4195+ 'DNS HA')
4196+ status_set('blocked', msg)
4197+ raise HAIncompleteConfig(msg)
4198+
4199+ log('VIP HA: VIP is set {}'.format(vip), level=DEBUG)
4200+ return True
4201+
4202+
4203+def canonical_url(configs, vip_setting='vip'):
4204+ '''
4205+ Returns the correct HTTP URL to this host given the state of HTTPS
4206+ configuration and hacluster.
4207+
4208+ :configs : OSTemplateRenderer: A config tempating object to inspect for
4209+ a complete https context.
4210+
4211+ :vip_setting: str: Setting in charm config that specifies
4212+ VIP address.
4213+ '''
4214+ scheme = 'http'
4215+ if 'https' in configs.complete_contexts():
4216+ scheme = 'https'
4217+ if is_clustered():
4218+ addr = config_get(vip_setting)
4219+ else:
4220+ addr = unit_get('private-address')
4221+ return '%s://%s' % (scheme, addr)
4222+
4223+
4224+def distributed_wait(modulo=None, wait=None, operation_name='operation'):
4225+ ''' Distribute operations by waiting based on modulo_distribution
4226+
4227+ If modulo and or wait are not set, check config_get for those values.
4228+ If config values are not set, default to modulo=3 and wait=30.
4229+
4230+ :param modulo: int The modulo number creates the group distribution
4231+ :param wait: int The constant time wait value
4232+ :param operation_name: string Operation name for status message
4233+ i.e. 'restart'
4234+ :side effect: Calls config_get()
4235+ :side effect: Calls log()
4236+ :side effect: Calls status_set()
4237+ :side effect: Calls time.sleep()
4238+ '''
4239+ if modulo is None:
4240+ modulo = config_get('modulo-nodes') or 3
4241+ if wait is None:
4242+ wait = config_get('known-wait') or 30
4243+ if juju_is_leader():
4244+ # The leader should never wait
4245+ calculated_wait = 0
4246+ else:
4247+ # non_zero_wait=True guarantees the non-leader who gets modulo 0
4248+ # will still wait
4249+ calculated_wait = modulo_distribution(modulo=modulo, wait=wait,
4250+ non_zero_wait=True)
4251+ msg = "Waiting {} seconds for {} ...".format(calculated_wait,
4252+ operation_name)
4253+ log(msg, DEBUG)
4254+ status_set('maintenance', msg)
4255+ time.sleep(calculated_wait)
4256+
4257+
4258+def get_managed_services_and_ports(services, external_ports,
4259+ external_services=None,
4260+ port_conv_f=determine_apache_port_single):
4261+ """Get the services and ports managed by this charm.
4262+
4263+ Return only the services and corresponding ports that are managed by this
4264+ charm. This excludes haproxy when there is a relation with hacluster. This
4265+ is because this charm passes responsability for stopping and starting
4266+ haproxy to hacluster.
4267+
4268+ Similarly, if a relation with hacluster exists then the ports returned by
4269+ this method correspond to those managed by the apache server rather than
4270+ haproxy.
4271+
4272+ :param services: List of services.
4273+ :type services: List[str]
4274+ :param external_ports: List of ports managed by external services.
4275+ :type external_ports: List[int]
4276+ :param external_services: List of services to be removed if ha relation is
4277+ present.
4278+ :type external_services: List[str]
4279+ :param port_conv_f: Function to apply to ports to calculate the ports
4280+ managed by services controlled by this charm.
4281+ :type port_convert_func: f()
4282+ :returns: A tuple containing a list of services first followed by a list of
4283+ ports.
4284+ :rtype: Tuple[List[str], List[int]]
4285+ """
4286+ if external_services is None:
4287+ external_services = ['haproxy']
4288+ if relation_ids('ha'):
4289+ for svc in external_services:
4290+ try:
4291+ services.remove(svc)
4292+ except ValueError:
4293+ pass
4294+ external_ports = [port_conv_f(p) for p in external_ports]
4295+ return services, external_ports
4296
4297=== added directory 'hooks/charmhelpers/contrib/hardening'
4298=== added file 'hooks/charmhelpers/contrib/hardening/README.hardening.md'
4299--- hooks/charmhelpers/contrib/hardening/README.hardening.md 1970-01-01 00:00:00 +0000
4300+++ hooks/charmhelpers/contrib/hardening/README.hardening.md 2021-05-12 04:07:51 +0000
4301@@ -0,0 +1,38 @@
4302+# Juju charm-helpers hardening library
4303+
4304+## Description
4305+
4306+This library provides multiple implementations of system and application
4307+hardening that conform to the standards of http://hardening.io/.
4308+
4309+Current implementations include:
4310+
4311+ * OS
4312+ * SSH
4313+ * MySQL
4314+ * Apache
4315+
4316+## Requirements
4317+
4318+* Juju Charms
4319+
4320+## Usage
4321+
4322+1. Synchronise this library into your charm and add the harden() decorator
4323+ (from contrib.hardening.harden) to any functions or methods you want to use
4324+ to trigger hardening of your application/system.
4325+
4326+2. Add a config option called 'harden' to your charm config.yaml and set it to
4327+ a space-delimited list of hardening modules you want to run e.g. "os ssh"
4328+
4329+3. Override any config defaults (contrib.hardening.defaults) by adding a file
4330+ called hardening.yaml to your charm root containing the name(s) of the
4331+ modules whose settings you want override at root level and then any settings
4332+ with overrides e.g.
4333+
4334+ os:
4335+ general:
4336+ desktop_enable: True
4337+
4338+4. Now just run your charm as usual and hardening will be applied each time the
4339+ hook runs.
4340
4341=== added file 'hooks/charmhelpers/contrib/hardening/__init__.py'
4342--- hooks/charmhelpers/contrib/hardening/__init__.py 1970-01-01 00:00:00 +0000
4343+++ hooks/charmhelpers/contrib/hardening/__init__.py 2021-05-12 04:07:51 +0000
4344@@ -0,0 +1,13 @@
4345+# Copyright 2016 Canonical Limited.
4346+#
4347+# Licensed under the Apache License, Version 2.0 (the "License");
4348+# you may not use this file except in compliance with the License.
4349+# You may obtain a copy of the License at
4350+#
4351+# http://www.apache.org/licenses/LICENSE-2.0
4352+#
4353+# Unless required by applicable law or agreed to in writing, software
4354+# distributed under the License is distributed on an "AS IS" BASIS,
4355+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4356+# See the License for the specific language governing permissions and
4357+# limitations under the License.
4358
4359=== added directory 'hooks/charmhelpers/contrib/hardening/apache'
4360=== added file 'hooks/charmhelpers/contrib/hardening/apache/__init__.py'
4361--- hooks/charmhelpers/contrib/hardening/apache/__init__.py 1970-01-01 00:00:00 +0000
4362+++ hooks/charmhelpers/contrib/hardening/apache/__init__.py 2021-05-12 04:07:51 +0000
4363@@ -0,0 +1,17 @@
4364+# Copyright 2016 Canonical Limited.
4365+#
4366+# Licensed under the Apache License, Version 2.0 (the "License");
4367+# you may not use this file except in compliance with the License.
4368+# You may obtain a copy of the License at
4369+#
4370+# http://www.apache.org/licenses/LICENSE-2.0
4371+#
4372+# Unless required by applicable law or agreed to in writing, software
4373+# distributed under the License is distributed on an "AS IS" BASIS,
4374+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4375+# See the License for the specific language governing permissions and
4376+# limitations under the License.
4377+
4378+from os import path
4379+
4380+TEMPLATES_DIR = path.join(path.dirname(__file__), 'templates')
4381
4382=== added directory 'hooks/charmhelpers/contrib/hardening/apache/checks'
4383=== added file 'hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py'
4384--- hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py 1970-01-01 00:00:00 +0000
4385+++ hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py 2021-05-12 04:07:51 +0000
4386@@ -0,0 +1,29 @@
4387+# Copyright 2016 Canonical Limited.
4388+#
4389+# Licensed under the Apache License, Version 2.0 (the "License");
4390+# you may not use this file except in compliance with the License.
4391+# You may obtain a copy of the License at
4392+#
4393+# http://www.apache.org/licenses/LICENSE-2.0
4394+#
4395+# Unless required by applicable law or agreed to in writing, software
4396+# distributed under the License is distributed on an "AS IS" BASIS,
4397+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4398+# See the License for the specific language governing permissions and
4399+# limitations under the License.
4400+
4401+from charmhelpers.core.hookenv import (
4402+ log,
4403+ DEBUG,
4404+)
4405+from charmhelpers.contrib.hardening.apache.checks import config
4406+
4407+
4408+def run_apache_checks():
4409+ log("Starting Apache hardening checks.", level=DEBUG)
4410+ checks = config.get_audits()
4411+ for check in checks:
4412+ log("Running '%s' check" % (check.__class__.__name__), level=DEBUG)
4413+ check.ensure_compliance()
4414+
4415+ log("Apache hardening checks complete.", level=DEBUG)
4416
4417=== added file 'hooks/charmhelpers/contrib/hardening/apache/checks/config.py'
4418--- hooks/charmhelpers/contrib/hardening/apache/checks/config.py 1970-01-01 00:00:00 +0000
4419+++ hooks/charmhelpers/contrib/hardening/apache/checks/config.py 2021-05-12 04:07:51 +0000
4420@@ -0,0 +1,104 @@
4421+# Copyright 2016 Canonical Limited.
4422+#
4423+# Licensed under the Apache License, Version 2.0 (the "License");
4424+# you may not use this file except in compliance with the License.
4425+# You may obtain a copy of the License at
4426+#
4427+# http://www.apache.org/licenses/LICENSE-2.0
4428+#
4429+# Unless required by applicable law or agreed to in writing, software
4430+# distributed under the License is distributed on an "AS IS" BASIS,
4431+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4432+# See the License for the specific language governing permissions and
4433+# limitations under the License.
4434+
4435+import os
4436+import re
4437+import six
4438+import subprocess
4439+
4440+
4441+from charmhelpers.core.hookenv import (
4442+ log,
4443+ INFO,
4444+)
4445+from charmhelpers.contrib.hardening.audits.file import (
4446+ FilePermissionAudit,
4447+ DirectoryPermissionAudit,
4448+ NoReadWriteForOther,
4449+ TemplatedFile,
4450+ DeletedFile
4451+)
4452+from charmhelpers.contrib.hardening.audits.apache import DisabledModuleAudit
4453+from charmhelpers.contrib.hardening.apache import TEMPLATES_DIR
4454+from charmhelpers.contrib.hardening import utils
4455+
4456+
4457+def get_audits():
4458+ """Get Apache hardening config audits.
4459+
4460+ :returns: dictionary of audits
4461+ """
4462+ if subprocess.call(['which', 'apache2'], stdout=subprocess.PIPE) != 0:
4463+ log("Apache server does not appear to be installed on this node - "
4464+ "skipping apache hardening", level=INFO)
4465+ return []
4466+
4467+ context = ApacheConfContext()
4468+ settings = utils.get_settings('apache')
4469+ audits = [
4470+ FilePermissionAudit(paths=os.path.join(
4471+ settings['common']['apache_dir'], 'apache2.conf'),
4472+ user='root', group='root', mode=0o0640),
4473+
4474+ TemplatedFile(os.path.join(settings['common']['apache_dir'],
4475+ 'mods-available/alias.conf'),
4476+ context,
4477+ TEMPLATES_DIR,
4478+ mode=0o0640,
4479+ user='root',
4480+ service_actions=[{'service': 'apache2',
4481+ 'actions': ['restart']}]),
4482+
4483+ TemplatedFile(os.path.join(settings['common']['apache_dir'],
4484+ 'conf-enabled/99-hardening.conf'),
4485+ context,
4486+ TEMPLATES_DIR,
4487+ mode=0o0640,
4488+ user='root',
4489+ service_actions=[{'service': 'apache2',
4490+ 'actions': ['restart']}]),
4491+
4492+ DirectoryPermissionAudit(settings['common']['apache_dir'],
4493+ user='root',
4494+ group='root',
4495+ mode=0o0750),
4496+
4497+ DisabledModuleAudit(settings['hardening']['modules_to_disable']),
4498+
4499+ NoReadWriteForOther(settings['common']['apache_dir']),
4500+
4501+ DeletedFile(['/var/www/html/index.html'])
4502+ ]
4503+
4504+ return audits
4505+
4506+
4507+class ApacheConfContext(object):
4508+ """Defines the set of key/value pairs to set in a apache config file.
4509+
4510+ This context, when called, will return a dictionary containing the
4511+ key/value pairs of setting to specify in the
4512+ /etc/apache/conf-enabled/hardening.conf file.
4513+ """
4514+ def __call__(self):
4515+ settings = utils.get_settings('apache')
4516+ ctxt = settings['hardening']
4517+
4518+ out = subprocess.check_output(['apache2', '-v'])
4519+ if six.PY3:
4520+ out = out.decode('utf-8')
4521+ ctxt['apache_version'] = re.search(r'.+version: Apache/(.+?)\s.+',
4522+ out).group(1)
4523+ ctxt['apache_icondir'] = '/usr/share/apache2/icons/'
4524+ return ctxt
4525
4526=== added directory 'hooks/charmhelpers/contrib/hardening/apache/templates'
4527=== added file 'hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf'
4528--- hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf 1970-01-01 00:00:00 +0000
4529+++ hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf 2021-05-12 04:07:51 +0000
4530@@ -0,0 +1,32 @@
4531+###############################################################################
4532+# WARNING: This configuration file is maintained by Juju. Local changes may
4533+# be overwritten.
4534+###############################################################################
4535+
4536+<Location / >
4537+ <LimitExcept {{ allowed_http_methods }} >
4538+ # http://httpd.apache.org/docs/2.4/upgrading.html
4539+ {% if apache_version > '2.2' -%}
4540+ Require all granted
4541+ {% else -%}
4542+ Order Allow,Deny
4543+ Deny from all
4544+ {% endif %}
4545+ </LimitExcept>
4546+</Location>
4547+
4548+<Directory />
4549+ Options -Indexes -FollowSymLinks
4550+ AllowOverride None
4551+</Directory>
4552+
4553+<Directory /var/www/>
4554+ Options -Indexes -FollowSymLinks
4555+ AllowOverride None
4556+</Directory>
4557+
4558+TraceEnable {{ traceenable }}
4559+ServerTokens {{ servertokens }}
4560+
4561+SSLHonorCipherOrder {{ honor_cipher_order }}
4562+SSLCipherSuite {{ cipher_suite }}
4563
4564=== added file 'hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf'
4565--- hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf 1970-01-01 00:00:00 +0000
4566+++ hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf 2021-05-12 04:07:51 +0000
4567@@ -0,0 +1,31 @@
4568+###############################################################################
4569+# WARNING: This configuration file is maintained by Juju. Local changes may
4570+# be overwritten.
4571+###############################################################################
4572+<IfModule alias_module>
4573+ #
4574+ # Aliases: Add here as many aliases as you need (with no limit). The format is
4575+ # Alias fakename realname
4576+ #
4577+ # Note that if you include a trailing / on fakename then the server will
4578+ # require it to be present in the URL. So "/icons" isn't aliased in this
4579+ # example, only "/icons/". If the fakename is slash-terminated, then the
4580+ # realname must also be slash terminated, and if the fakename omits the
4581+ # trailing slash, the realname must also omit it.
4582+ #
4583+ # We include the /icons/ alias for FancyIndexed directory listings. If
4584+ # you do not use FancyIndexing, you may comment this out.
4585+ #
4586+ Alias /icons/ "{{ apache_icondir }}/"
4587+
4588+ <Directory "{{ apache_icondir }}">
4589+ Options -Indexes -MultiViews -FollowSymLinks
4590+ AllowOverride None
4591+{% if apache_version == '2.4' -%}
4592+ Require all granted
4593+{% else -%}
4594+ Order allow,deny
4595+ Allow from all
4596+{% endif %}
4597+ </Directory>
4598+</IfModule>
4599
4600=== added directory 'hooks/charmhelpers/contrib/hardening/audits'
4601=== added file 'hooks/charmhelpers/contrib/hardening/audits/__init__.py'
4602--- hooks/charmhelpers/contrib/hardening/audits/__init__.py 1970-01-01 00:00:00 +0000
4603+++ hooks/charmhelpers/contrib/hardening/audits/__init__.py 2021-05-12 04:07:51 +0000
4604@@ -0,0 +1,54 @@
4605+# Copyright 2016 Canonical Limited.
4606+#
4607+# Licensed under the Apache License, Version 2.0 (the "License");
4608+# you may not use this file except in compliance with the License.
4609+# You may obtain a copy of the License at
4610+#
4611+# http://www.apache.org/licenses/LICENSE-2.0
4612+#
4613+# Unless required by applicable law or agreed to in writing, software
4614+# distributed under the License is distributed on an "AS IS" BASIS,
4615+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4616+# See the License for the specific language governing permissions and
4617+# limitations under the License.
4618+
4619+
4620+class BaseAudit(object): # NO-QA
4621+ """Base class for hardening checks.
4622+
4623+ The lifecycle of a hardening check is to first check to see if the system
4624+ is in compliance for the specified check. If it is not in compliance, the
4625+ check method will return a value which will be supplied to the.
4626+ """
4627+ def __init__(self, *args, **kwargs):
4628+ self.unless = kwargs.get('unless', None)
4629+ super(BaseAudit, self).__init__()
4630+
4631+ def ensure_compliance(self):
4632+ """Checks to see if the current hardening check is in compliance or
4633+ not.
4634+
4635+ If the check that is performed is not in compliance, then an exception
4636+ should be raised.
4637+ """
4638+ pass
4639+
4640+ def _take_action(self):
4641+ """Determines whether to perform the action or not.
4642+
4643+ Checks whether or not an action should be taken. This is determined by
4644+ the truthy value for the unless parameter. If unless is a callback
4645+ method, it will be invoked with no parameters in order to determine
4646+ whether or not the action should be taken. Otherwise, the truthy value
4647+ of the unless attribute will determine if the action should be
4648+ performed.
4649+ """
4650+ # Do the action if there isn't an unless override.
4651+ if self.unless is None:
4652+ return True
4653+
4654+ # Invoke the callback if there is one.
4655+ if hasattr(self.unless, '__call__'):
4656+ return not self.unless()
4657+
4658+ return not self.unless
4659
4660=== added file 'hooks/charmhelpers/contrib/hardening/audits/apache.py'
4661--- hooks/charmhelpers/contrib/hardening/audits/apache.py 1970-01-01 00:00:00 +0000
4662+++ hooks/charmhelpers/contrib/hardening/audits/apache.py 2021-05-12 04:07:51 +0000
4663@@ -0,0 +1,105 @@
4664+# Copyright 2016 Canonical Limited.
4665+#
4666+# Licensed under the Apache License, Version 2.0 (the "License");
4667+# you may not use this file except in compliance with the License.
4668+# You may obtain a copy of the License at
4669+#
4670+# http://www.apache.org/licenses/LICENSE-2.0
4671+#
4672+# Unless required by applicable law or agreed to in writing, software
4673+# distributed under the License is distributed on an "AS IS" BASIS,
4674+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4675+# See the License for the specific language governing permissions and
4676+# limitations under the License.
4677+
4678+import re
4679+import subprocess
4680+
4681+import six
4682+
4683+from charmhelpers.core.hookenv import (
4684+ log,
4685+ INFO,
4686+ ERROR,
4687+)
4688+
4689+from charmhelpers.contrib.hardening.audits import BaseAudit
4690+
4691+
4692+class DisabledModuleAudit(BaseAudit):
4693+ """Audits Apache2 modules.
4694+
4695+ Determines if the apache2 modules are enabled. If the modules are enabled
4696+ then they are removed in the ensure_compliance.
4697+ """
4698+ def __init__(self, modules):
4699+ if modules is None:
4700+ self.modules = []
4701+ elif isinstance(modules, six.string_types):
4702+ self.modules = [modules]
4703+ else:
4704+ self.modules = modules
4705+
4706+ def ensure_compliance(self):
4707+ """Ensures that the modules are not loaded."""
4708+ if not self.modules:
4709+ return
4710+
4711+ try:
4712+ loaded_modules = self._get_loaded_modules()
4713+ non_compliant_modules = []
4714+ for module in self.modules:
4715+ if module in loaded_modules:
4716+ log("Module '%s' is enabled but should not be." %
4717+ (module), level=INFO)
4718+ non_compliant_modules.append(module)
4719+
4720+ if len(non_compliant_modules) == 0:
4721+ return
4722+
4723+ for module in non_compliant_modules:
4724+ self._disable_module(module)
4725+ self._restart_apache()
4726+ except subprocess.CalledProcessError as e:
4727+ log('Error occurred auditing apache module compliance. '
4728+ 'This may have been already reported. '
4729+ 'Output is: %s' % e.output, level=ERROR)
4730+
4731+ @staticmethod
4732+ def _get_loaded_modules():
4733+ """Returns the modules which are enabled in Apache."""
4734+ output = subprocess.check_output(['apache2ctl', '-M'])
4735+ if six.PY3:
4736+ output = output.decode('utf-8')
4737+ modules = []
4738+ for line in output.splitlines():
4739+ # Each line of the enabled module output looks like:
4740+ # module_name (static|shared)
4741+ # Plus a header line at the top of the output which is stripped
4742+ # out by the regex.
4743+ matcher = re.search(r'^ (\S*)_module (\S*)', line)
4744+ if matcher:
4745+ modules.append(matcher.group(1))
4746+ return modules
4747+
4748+ @staticmethod
4749+ def _disable_module(module):
4750+ """Disables the specified module in Apache."""
4751+ try:
4752+ subprocess.check_call(['a2dismod', module])
4753+ except subprocess.CalledProcessError as e:
4754+ # Note: catch error here to allow the attempt of disabling
4755+ # multiple modules in one go rather than failing after the
4756+ # first module fails.
4757+ log('Error occurred disabling module %s. '
4758+ 'Output is: %s' % (module, e.output), level=ERROR)
4759+
4760+ @staticmethod
4761+ def _restart_apache():
4762+ """Restarts the apache process"""
4763+ subprocess.check_output(['service', 'apache2', 'restart'])
4764+
4765+ @staticmethod
4766+ def is_ssl_enabled():
4767+ """Check if SSL module is enabled or not"""
4768+ return 'ssl' in DisabledModuleAudit._get_loaded_modules()
4769
4770=== added file 'hooks/charmhelpers/contrib/hardening/audits/apt.py'
4771--- hooks/charmhelpers/contrib/hardening/audits/apt.py 1970-01-01 00:00:00 +0000
4772+++ hooks/charmhelpers/contrib/hardening/audits/apt.py 2021-05-12 04:07:51 +0000
4773@@ -0,0 +1,104 @@
4774+# Copyright 2016 Canonical Limited.
4775+#
4776+# Licensed under the Apache License, Version 2.0 (the "License");
4777+# you may not use this file except in compliance with the License.
4778+# You may obtain a copy of the License at
4779+#
4780+# http://www.apache.org/licenses/LICENSE-2.0
4781+#
4782+# Unless required by applicable law or agreed to in writing, software
4783+# distributed under the License is distributed on an "AS IS" BASIS,
4784+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4785+# See the License for the specific language governing permissions and
4786+# limitations under the License.
4787+
4788+from __future__ import absolute_import # required for external apt import
4789+from six import string_types
4790+
4791+from charmhelpers.fetch import (
4792+ apt_cache,
4793+ apt_purge
4794+)
4795+from charmhelpers.core.hookenv import (
4796+ log,
4797+ DEBUG,
4798+ WARNING,
4799+)
4800+from charmhelpers.contrib.hardening.audits import BaseAudit
4801+from charmhelpers.fetch import ubuntu_apt_pkg as apt_pkg
4802+
4803+
4804+class AptConfig(BaseAudit):
4805+
4806+ def __init__(self, config, **kwargs):
4807+ self.config = config
4808+
4809+ def verify_config(self):
4810+ apt_pkg.init()
4811+ for cfg in self.config:
4812+ value = apt_pkg.config.get(cfg['key'], cfg.get('default', ''))
4813+ if value and value != cfg['expected']:
4814+ log("APT config '%s' has unexpected value '%s' "
4815+ "(expected='%s')" %
4816+ (cfg['key'], value, cfg['expected']), level=WARNING)
4817+
4818+ def ensure_compliance(self):
4819+ self.verify_config()
4820+
4821+
4822+class RestrictedPackages(BaseAudit):
4823+ """Class used to audit restricted packages on the system."""
4824+
4825+ def __init__(self, pkgs, **kwargs):
4826+ super(RestrictedPackages, self).__init__(**kwargs)
4827+ if isinstance(pkgs, string_types) or not hasattr(pkgs, '__iter__'):
4828+ self.pkgs = pkgs.split()
4829+ else:
4830+ self.pkgs = pkgs
4831+
4832+ def ensure_compliance(self):
4833+ cache = apt_cache()
4834+
4835+ for p in self.pkgs:
4836+ if p not in cache:
4837+ continue
4838+
4839+ pkg = cache[p]
4840+ if not self.is_virtual_package(pkg):
4841+ if not pkg.current_ver:
4842+ log("Package '%s' is not installed." % pkg.name,
4843+ level=DEBUG)
4844+ continue
4845+ else:
4846+ log("Restricted package '%s' is installed" % pkg.name,
4847+ level=WARNING)
4848+ self.delete_package(cache, pkg)
4849+ else:
4850+ log("Checking restricted virtual package '%s' provides" %
4851+ pkg.name, level=DEBUG)
4852+ self.delete_package(cache, pkg)
4853+
4854+ def delete_package(self, cache, pkg):
4855+ """Deletes the package from the system.
4856+
4857+ Deletes the package form the system, properly handling virtual
4858+ packages.
4859+
4860+ :param cache: the apt cache
4861+ :param pkg: the package to remove
4862+ """
4863+ if self.is_virtual_package(pkg):
4864+ log("Package '%s' appears to be virtual - purging provides" %
4865+ pkg.name, level=DEBUG)
4866+ for _p in pkg.provides_list:
4867+ self.delete_package(cache, _p[2].parent_pkg)
4868+ elif not pkg.current_ver:
4869+ log("Package '%s' not installed" % pkg.name, level=DEBUG)
4870+ return
4871+ else:
4872+ log("Purging package '%s'" % pkg.name, level=DEBUG)
4873+ apt_purge(pkg.name)
4874+
4875+ def is_virtual_package(self, pkg):
4876+ return (pkg.get('has_provides', False) and
4877+ not pkg.get('has_versions', False))
4878
4879=== added file 'hooks/charmhelpers/contrib/hardening/audits/file.py'
4880--- hooks/charmhelpers/contrib/hardening/audits/file.py 1970-01-01 00:00:00 +0000
4881+++ hooks/charmhelpers/contrib/hardening/audits/file.py 2021-05-12 04:07:51 +0000
4882@@ -0,0 +1,550 @@
4883+# Copyright 2016 Canonical Limited.
4884+#
4885+# Licensed under the Apache License, Version 2.0 (the "License");
4886+# you may not use this file except in compliance with the License.
4887+# You may obtain a copy of the License at
4888+#
4889+# http://www.apache.org/licenses/LICENSE-2.0
4890+#
4891+# Unless required by applicable law or agreed to in writing, software
4892+# distributed under the License is distributed on an "AS IS" BASIS,
4893+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
4894+# See the License for the specific language governing permissions and
4895+# limitations under the License.
4896+
4897+import grp
4898+import os
4899+import pwd
4900+import re
4901+
4902+from subprocess import (
4903+ CalledProcessError,
4904+ check_output,
4905+ check_call,
4906+)
4907+from traceback import format_exc
4908+from six import string_types
4909+from stat import (
4910+ S_ISGID,
4911+ S_ISUID
4912+)
4913+
4914+from charmhelpers.core.hookenv import (
4915+ log,
4916+ DEBUG,
4917+ INFO,
4918+ WARNING,
4919+ ERROR,
4920+)
4921+from charmhelpers.core import unitdata
4922+from charmhelpers.core.host import file_hash
4923+from charmhelpers.contrib.hardening.audits import BaseAudit
4924+from charmhelpers.contrib.hardening.templating import (
4925+ get_template_path,
4926+ render_and_write,
4927+)
4928+from charmhelpers.contrib.hardening import utils
4929+
4930+
4931+class BaseFileAudit(BaseAudit):
4932+ """Base class for file audits.
4933+
4934+ Provides api stubs for compliance check flow that must be used by any class
4935+ that implemented this one.
4936+ """
4937+
4938+ def __init__(self, paths, always_comply=False, *args, **kwargs):
4939+ """
4940+ :param paths: string path of list of paths of files we want to apply
4941+ compliance checks are criteria to.
4942+ :param always_comply: if true compliance criteria is always applied
4943+ else compliance is skipped for non-existent
4944+ paths.
4945+ """
4946+ super(BaseFileAudit, self).__init__(*args, **kwargs)
4947+ self.always_comply = always_comply
4948+ if isinstance(paths, string_types) or not hasattr(paths, '__iter__'):
4949+ self.paths = [paths]
4950+ else:
4951+ self.paths = paths
4952+
4953+ def ensure_compliance(self):
4954+ """Ensure that the all registered files comply to registered criteria.
4955+ """
4956+ for p in self.paths:
4957+ if os.path.exists(p):
4958+ if self.is_compliant(p):
4959+ continue
4960+
4961+ log('File %s is not in compliance.' % p, level=INFO)
4962+ else:
4963+ if not self.always_comply:
4964+ log("Non-existent path '%s' - skipping compliance check"
4965+ % (p), level=INFO)
4966+ continue
4967+
4968+ if self._take_action():
4969+ log("Applying compliance criteria to '%s'" % (p), level=INFO)
4970+ self.comply(p)
4971+
4972+ def is_compliant(self, path):
4973+ """Audits the path to see if it is compliance.
4974+
4975+ :param path: the path to the file that should be checked.
4976+ """
4977+ raise NotImplementedError
4978+
4979+ def comply(self, path):
4980+ """Enforces the compliance of a path.
4981+
4982+ :param path: the path to the file that should be enforced.
4983+ """
4984+ raise NotImplementedError
4985+
4986+ @classmethod
4987+ def _get_stat(cls, path):
4988+ """Returns the Posix st_stat information for the specified file path.
4989+
4990+ :param path: the path to get the st_stat information for.
4991+ :returns: an st_stat object for the path or None if the path doesn't
4992+ exist.
4993+ """
4994+ return os.stat(path)
4995+
4996+
4997+class FilePermissionAudit(BaseFileAudit):
4998+ """Implements an audit for file permissions and ownership for a user.
4999+
5000+ This class implements functionality that ensures that a specific user/group
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches