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
=== modified file 'hooks/charmhelpers/__init__.py'
--- hooks/charmhelpers/__init__.py 2016-12-20 14:35:00 +0000
+++ hooks/charmhelpers/__init__.py 2021-05-12 04:07:51 +0000
@@ -14,23 +14,86 @@
1414
15# Bootstrap charm-helpers, installing its dependencies if necessary using15# Bootstrap charm-helpers, installing its dependencies if necessary using
16# only standard libraries.16# only standard libraries.
17from __future__ import print_function
18from __future__ import absolute_import
19
20import functools
21import inspect
17import subprocess22import subprocess
18import sys23import sys
1924
20try:25try:
21 import six # flake8: noqa26 import six # NOQA:F401
22except ImportError:27except ImportError:
23 if sys.version_info.major == 2:28 if sys.version_info.major == 2:
24 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])29 subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
25 else:30 else:
26 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])31 subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
27 import six # flake8: noqa32 import six # NOQA:F401
2833
29try:34try:
30 import yaml # flake8: noqa35 import yaml # NOQA:F401
31except ImportError:36except ImportError:
32 if sys.version_info.major == 2:37 if sys.version_info.major == 2:
33 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])38 subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
34 else:39 else:
35 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])40 subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
36 import yaml # flake8: noqa41 import yaml # NOQA:F401
42
43
44# Holds a list of mapping of mangled function names that have been deprecated
45# using the @deprecate decorator below. This is so that the warning is only
46# printed once for each usage of the function.
47__deprecated_functions = {}
48
49
50def deprecate(warning, date=None, log=None):
51 """Add a deprecation warning the first time the function is used.
52
53 The date which is a string in semi-ISO8660 format indicates the year-month
54 that the function is officially going to be removed.
55
56 usage:
57
58 @deprecate('use core/fetch/add_source() instead', '2017-04')
59 def contributed_add_source_thing(...):
60 ...
61
62 And it then prints to the log ONCE that the function is deprecated.
63 The reason for passing the logging function (log) is so that hookenv.log
64 can be used for a charm if needed.
65
66 :param warning: String to indicate what is to be used instead.
67 :param date: Optional string in YYYY-MM format to indicate when the
68 function will definitely (probably) be removed.
69 :param log: The log function to call in order to log. If None, logs to
70 stdout
71 """
72 def wrap(f):
73
74 @functools.wraps(f)
75 def wrapped_f(*args, **kwargs):
76 try:
77 module = inspect.getmodule(f)
78 file = inspect.getsourcefile(f)
79 lines = inspect.getsourcelines(f)
80 f_name = "{}-{}-{}..{}-{}".format(
81 module.__name__, file, lines[0], lines[-1], f.__name__)
82 except (IOError, TypeError):
83 # assume it was local, so just use the name of the function
84 f_name = f.__name__
85 if f_name not in __deprecated_functions:
86 __deprecated_functions[f_name] = True
87 s = "DEPRECATION WARNING: Function {} is being removed".format(
88 f.__name__)
89 if date:
90 s = "{} on/around {}".format(s, date)
91 if warning:
92 s = "{} : {}".format(s, warning)
93 if log:
94 log(s)
95 else:
96 print(s)
97 return f(*args, **kwargs)
98 return wrapped_f
99 return wrap
37100
=== added directory 'hooks/charmhelpers/cli'
=== added file 'hooks/charmhelpers/cli/README.rst'
--- hooks/charmhelpers/cli/README.rst 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/README.rst 2021-05-12 04:07:51 +0000
@@ -0,0 +1,57 @@
1==========
2Commandant
3==========
4
5-----------------------------------------------------
6Automatic command-line interfaces to Python functions
7-----------------------------------------------------
8
9One 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.
10
11Command 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.
12
13Goals
14=====
15
16* Single decorator to expose a function as a command.
17 * now two decorators - one "automatic" and one that allows authors to manipulate the arguments for fine-grained control.(MW)
18* Automatic analysis of function signature through ``inspect.getargspec()`` on python 2 or ``inspect.getfullargspec()`` on python 3
19* Command argument parser built automatically with ``argparse``
20* Interactive interpreter loop object made with ``Cmd``
21* Options to output structured return value data via ``pprint``, ``yaml`` or ``json`` dumps.
22
23Other Important Features that need writing
24------------------------------------------
25
26* Help and Usage documentation can be automatically generated, but it will be important to let users override this behaviour
27* 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.
28 - Filename arguments are important, as good practice is for functions to accept file objects as parameters.
29 - choices arguments help to limit bad input before the function is called
30* Some automatic behaviour could make for better defaults, once the user can override them.
31 - We could automatically detect arguments that default to False or True, and automatically support --no-foo for foo=True.
32 - We could automatically support hyphens as alternates for underscores
33 - Arguments defaulting to sequence types could support the ``append`` action.
34
35
36-----------------------------------------------------
37Implementing subcommands
38-----------------------------------------------------
39
40(WIP)
41
42So 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.
43
44Some examples::
45
46 from charmhelpers.cli import CommandLine
47 from charmhelpers.payload import execd
48 from charmhelpers.foo import bar
49
50 cli = CommandLine()
51
52 cli.subcommand(execd.execd_run)
53
54 @cli.subcommand_builder("bar", help="Bar baz qux")
55 def barcmd_builder(subparser):
56 subparser.add_argument('argument1', help="yackety")
57 return bar
058
=== added file 'hooks/charmhelpers/cli/__init__.py'
--- hooks/charmhelpers/cli/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,196 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import inspect
16import argparse
17import sys
18
19import six
20from six.moves import zip
21
22import charmhelpers.core.unitdata
23
24
25class OutputFormatter(object):
26 def __init__(self, outfile=sys.stdout):
27 self.formats = (
28 "raw",
29 "json",
30 "py",
31 "yaml",
32 "csv",
33 "tab",
34 )
35 self.outfile = outfile
36
37 def add_arguments(self, argument_parser):
38 formatgroup = argument_parser.add_mutually_exclusive_group()
39 choices = self.supported_formats
40 formatgroup.add_argument("--format", metavar='FMT',
41 help="Select output format for returned data, "
42 "where FMT is one of: {}".format(choices),
43 choices=choices, default='raw')
44 for fmt in self.formats:
45 fmtfunc = getattr(self, fmt)
46 formatgroup.add_argument("-{}".format(fmt[0]),
47 "--{}".format(fmt), action='store_const',
48 const=fmt, dest='format',
49 help=fmtfunc.__doc__)
50
51 @property
52 def supported_formats(self):
53 return self.formats
54
55 def raw(self, output):
56 """Output data as raw string (default)"""
57 if isinstance(output, (list, tuple)):
58 output = '\n'.join(map(str, output))
59 self.outfile.write(str(output))
60
61 def py(self, output):
62 """Output data as a nicely-formatted python data structure"""
63 import pprint
64 pprint.pprint(output, stream=self.outfile)
65
66 def json(self, output):
67 """Output data in JSON format"""
68 import json
69 json.dump(output, self.outfile)
70
71 def yaml(self, output):
72 """Output data in YAML format"""
73 import yaml
74 yaml.safe_dump(output, self.outfile)
75
76 def csv(self, output):
77 """Output data as excel-compatible CSV"""
78 import csv
79 csvwriter = csv.writer(self.outfile)
80 csvwriter.writerows(output)
81
82 def tab(self, output):
83 """Output data in excel-compatible tab-delimited format"""
84 import csv
85 csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
86 csvwriter.writerows(output)
87
88 def format_output(self, output, fmt='raw'):
89 fmtfunc = getattr(self, fmt)
90 fmtfunc(output)
91
92
93class CommandLine(object):
94 argument_parser = None
95 subparsers = None
96 formatter = None
97 exit_code = 0
98
99 def __init__(self):
100 if not self.argument_parser:
101 self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
102 if not self.formatter:
103 self.formatter = OutputFormatter()
104 self.formatter.add_arguments(self.argument_parser)
105 if not self.subparsers:
106 self.subparsers = self.argument_parser.add_subparsers(help='Commands')
107
108 def subcommand(self, command_name=None):
109 """
110 Decorate a function as a subcommand. Use its arguments as the
111 command-line arguments"""
112 def wrapper(decorated):
113 cmd_name = command_name or decorated.__name__
114 subparser = self.subparsers.add_parser(cmd_name,
115 description=decorated.__doc__)
116 for args, kwargs in describe_arguments(decorated):
117 subparser.add_argument(*args, **kwargs)
118 subparser.set_defaults(func=decorated)
119 return decorated
120 return wrapper
121
122 def test_command(self, decorated):
123 """
124 Subcommand is a boolean test function, so bool return values should be
125 converted to a 0/1 exit code.
126 """
127 decorated._cli_test_command = True
128 return decorated
129
130 def no_output(self, decorated):
131 """
132 Subcommand is not expected to return a value, so don't print a spurious None.
133 """
134 decorated._cli_no_output = True
135 return decorated
136
137 def subcommand_builder(self, command_name, description=None):
138 """
139 Decorate a function that builds a subcommand. Builders should accept a
140 single argument (the subparser instance) and return the function to be
141 run as the command."""
142 def wrapper(decorated):
143 subparser = self.subparsers.add_parser(command_name)
144 func = decorated(subparser)
145 subparser.set_defaults(func=func)
146 subparser.description = description or func.__doc__
147 return wrapper
148
149 def run(self):
150 "Run cli, processing arguments and executing subcommands."
151 arguments = self.argument_parser.parse_args()
152 if six.PY2:
153 argspec = inspect.getargspec(arguments.func)
154 else:
155 argspec = inspect.getfullargspec(arguments.func)
156 vargs = []
157 for arg in argspec.args:
158 vargs.append(getattr(arguments, arg))
159 if argspec.varargs:
160 vargs.extend(getattr(arguments, argspec.varargs))
161 output = arguments.func(*vargs)
162 if getattr(arguments.func, '_cli_test_command', False):
163 self.exit_code = 0 if output else 1
164 output = ''
165 if getattr(arguments.func, '_cli_no_output', False):
166 output = ''
167 self.formatter.format_output(output, arguments.format)
168 if charmhelpers.core.unitdata._KV:
169 charmhelpers.core.unitdata._KV.flush()
170
171
172cmdline = CommandLine()
173
174
175def describe_arguments(func):
176 """
177 Analyze a function's signature and return a data structure suitable for
178 passing in as arguments to an argparse parser's add_argument() method."""
179
180 if six.PY2:
181 argspec = inspect.getargspec(func)
182 else:
183 argspec = inspect.getfullargspec(func)
184 # we should probably raise an exception somewhere if func includes **kwargs
185 if argspec.defaults:
186 positional_args = argspec.args[:-len(argspec.defaults)]
187 keyword_names = argspec.args[-len(argspec.defaults):]
188 for arg, default in zip(keyword_names, argspec.defaults):
189 yield ('--{}'.format(arg),), {'default': default}
190 else:
191 positional_args = argspec.args
192
193 for arg in positional_args:
194 yield (arg,), {}
195 if argspec.varargs:
196 yield (argspec.varargs,), {'nargs': '*'}
0197
=== added file 'hooks/charmhelpers/cli/benchmark.py'
--- hooks/charmhelpers/cli/benchmark.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/benchmark.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,34 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from . import cmdline
16from charmhelpers.contrib.benchmark import Benchmark
17
18
19@cmdline.subcommand(command_name='benchmark-start')
20def start():
21 Benchmark.start()
22
23
24@cmdline.subcommand(command_name='benchmark-finish')
25def finish():
26 Benchmark.finish()
27
28
29@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
30def service(subparser):
31 subparser.add_argument("value", help="The composite score.")
32 subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
33 subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
34 return Benchmark.set_composite_score
035
=== added file 'hooks/charmhelpers/cli/commands.py'
--- hooks/charmhelpers/cli/commands.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/commands.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,30 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""
16This module loads sub-modules into the python runtime so they can be
17discovered via the inspect module. In order to prevent flake8 from (rightfully)
18telling us these are unused modules, throw a ' # noqa' at the end of each import
19so that the warning is suppressed.
20"""
21
22from . import CommandLine # noqa
23
24"""
25Import the sub-modules which have decorated subcommands to register with chlp.
26"""
27from . import host # noqa
28from . import benchmark # noqa
29from . import unitdata # noqa
30from . import hookenv # noqa
031
=== added file 'hooks/charmhelpers/cli/hookenv.py'
--- hooks/charmhelpers/cli/hookenv.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/hookenv.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,21 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from . import cmdline
16from charmhelpers.core import hookenv
17
18
19cmdline.subcommand('relation-id')(hookenv.relation_id._wrapped)
20cmdline.subcommand('service-name')(hookenv.service_name)
21cmdline.subcommand('remote-service-name')(hookenv.remote_service_name._wrapped)
022
=== added file 'hooks/charmhelpers/cli/host.py'
--- hooks/charmhelpers/cli/host.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/host.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,29 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from . import cmdline
16from charmhelpers.core import host
17
18
19@cmdline.subcommand()
20def mounts():
21 "List mounts"
22 return host.mounts()
23
24
25@cmdline.subcommand_builder('service', description="Control system services")
26def service(subparser):
27 subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
28 subparser.add_argument("service_name", help="Name of the service to control")
29 return host.service
030
=== added file 'hooks/charmhelpers/cli/unitdata.py'
--- hooks/charmhelpers/cli/unitdata.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/cli/unitdata.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,46 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from . import cmdline
16from charmhelpers.core import unitdata
17
18
19@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
20def unitdata_cmd(subparser):
21 nested = subparser.add_subparsers()
22
23 get_cmd = nested.add_parser('get', help='Retrieve data')
24 get_cmd.add_argument('key', help='Key to retrieve the value of')
25 get_cmd.set_defaults(action='get', value=None)
26
27 getrange_cmd = nested.add_parser('getrange', help='Retrieve multiple data')
28 getrange_cmd.add_argument('key', metavar='prefix',
29 help='Prefix of the keys to retrieve')
30 getrange_cmd.set_defaults(action='getrange', value=None)
31
32 set_cmd = nested.add_parser('set', help='Store data')
33 set_cmd.add_argument('key', help='Key to set')
34 set_cmd.add_argument('value', help='Value to store')
35 set_cmd.set_defaults(action='set')
36
37 def _unitdata_cmd(action, key, value):
38 if action == 'get':
39 return unitdata.kv().get(key)
40 elif action == 'getrange':
41 return unitdata.kv().getrange(key)
42 elif action == 'set':
43 unitdata.kv().set(key, value)
44 unitdata.kv().flush()
45 return ''
46 return _unitdata_cmd
047
=== added file 'hooks/charmhelpers/context.py'
--- hooks/charmhelpers/context.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/context.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,205 @@
1# Copyright 2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15'''
16A Pythonic API to interact with the charm hook environment.
17
18:author: Stuart Bishop <stuart.bishop@canonical.com>
19'''
20
21import six
22
23from charmhelpers.core import hookenv
24
25from collections import OrderedDict
26if six.PY3:
27 from collections import UserDict # pragma: nocover
28else:
29 from UserDict import IterableUserDict as UserDict # pragma: nocover
30
31
32class Relations(OrderedDict):
33 '''Mapping relation name -> relation id -> Relation.
34
35 >>> rels = Relations()
36 >>> rels['sprog']['sprog:12']['client/6']['widget']
37 'remote widget'
38 >>> rels['sprog']['sprog:12'].local['widget'] = 'local widget'
39 >>> rels['sprog']['sprog:12'].local['widget']
40 'local widget'
41 >>> rels.peer.local['widget']
42 'local widget on the peer relation'
43 '''
44 def __init__(self):
45 super(Relations, self).__init__()
46 for relname in sorted(hookenv.relation_types()):
47 self[relname] = OrderedDict()
48 relids = hookenv.relation_ids(relname)
49 relids.sort(key=lambda x: int(x.split(':', 1)[-1]))
50 for relid in relids:
51 self[relname][relid] = Relation(relid)
52
53 @property
54 def peer(self):
55 peer_relid = hookenv.peer_relation_id()
56 for rels in self.values():
57 if peer_relid in rels:
58 return rels[peer_relid]
59
60
61class Relation(OrderedDict):
62 '''Mapping of unit -> remote RelationInfo for a relation.
63
64 This is an OrderedDict mapping, ordered numerically by
65 by unit number.
66
67 Also provides access to the local RelationInfo, and peer RelationInfo
68 instances by the 'local' and 'peers' attributes.
69
70 >>> r = Relation('sprog:12')
71 >>> r.keys()
72 ['client/9', 'client/10'] # Ordered numerically
73 >>> r['client/10']['widget'] # A remote RelationInfo setting
74 'remote widget'
75 >>> r.local['widget'] # The local RelationInfo setting
76 'local widget'
77 '''
78 relid = None # The relation id.
79 relname = None # The relation name (also known as relation type).
80 service = None # The remote service name, if known.
81 local = None # The local end's RelationInfo.
82 peers = None # Map of peer -> RelationInfo. None if no peer relation.
83
84 def __init__(self, relid):
85 remote_units = hookenv.related_units(relid)
86 remote_units.sort(key=lambda u: int(u.split('/', 1)[-1]))
87 super(Relation, self).__init__((unit, RelationInfo(relid, unit))
88 for unit in remote_units)
89
90 self.relname = relid.split(':', 1)[0]
91 self.relid = relid
92 self.local = RelationInfo(relid, hookenv.local_unit())
93
94 for relinfo in self.values():
95 self.service = relinfo.service
96 break
97
98 # If we have peers, and they have joined both the provided peer
99 # relation and this relation, we can peek at their data too.
100 # This is useful for creating consensus without leadership.
101 peer_relid = hookenv.peer_relation_id()
102 if peer_relid and peer_relid != relid:
103 peers = hookenv.related_units(peer_relid)
104 if peers:
105 peers.sort(key=lambda u: int(u.split('/', 1)[-1]))
106 self.peers = OrderedDict((peer, RelationInfo(relid, peer))
107 for peer in peers)
108 else:
109 self.peers = OrderedDict()
110 else:
111 self.peers = None
112
113 def __str__(self):
114 return '{} ({})'.format(self.relid, self.service)
115
116
117class RelationInfo(UserDict):
118 '''The bag of data at an end of a relation.
119
120 Every unit participating in a relation has a single bag of
121 data associated with that relation. This is that bag.
122
123 The bag of data for the local unit may be updated. Remote data
124 is immutable and will remain static for the duration of the hook.
125
126 Changes made to the local units relation data only become visible
127 to other units after the hook completes successfully. If the hook
128 does not complete successfully, the changes are rolled back.
129
130 Unlike standard Python mappings, setting an item to None is the
131 same as deleting it.
132
133 >>> relinfo = RelationInfo('db:12') # Default is the local unit.
134 >>> relinfo['user'] = 'fred'
135 >>> relinfo['user']
136 'fred'
137 >>> relinfo['user'] = None
138 >>> 'fred' in relinfo
139 False
140
141 This class wraps hookenv.relation_get and hookenv.relation_set.
142 All caching is left up to these two methods to avoid synchronization
143 issues. Data is only loaded on demand.
144 '''
145 relid = None # The relation id.
146 relname = None # The relation name (also know as the relation type).
147 unit = None # The unit id.
148 number = None # The unit number (integer).
149 service = None # The service name.
150
151 def __init__(self, relid, unit):
152 self.relname = relid.split(':', 1)[0]
153 self.relid = relid
154 self.unit = unit
155 self.service, num = self.unit.split('/', 1)
156 self.number = int(num)
157
158 def __str__(self):
159 return '{} ({})'.format(self.relid, self.unit)
160
161 @property
162 def data(self):
163 return hookenv.relation_get(rid=self.relid, unit=self.unit)
164
165 def __setitem__(self, key, value):
166 if self.unit != hookenv.local_unit():
167 raise TypeError('Attempting to set {} on remote unit {}'
168 ''.format(key, self.unit))
169 if value is not None and not isinstance(value, six.string_types):
170 # We don't do implicit casting. This would cause simple
171 # types like integers to be read back as strings in subsequent
172 # hooks, and mutable types would require a lot of wrapping
173 # to ensure relation-set gets called when they are mutated.
174 raise ValueError('Only string values allowed')
175 hookenv.relation_set(self.relid, {key: value})
176
177 def __delitem__(self, key):
178 # Deleting a key and setting it to null is the same thing in
179 # Juju relations.
180 self[key] = None
181
182
183class Leader(UserDict):
184 def __init__(self):
185 pass # Don't call superclass initializer, as it will nuke self.data
186
187 @property
188 def data(self):
189 return hookenv.leader_get()
190
191 def __setitem__(self, key, value):
192 if not hookenv.is_leader():
193 raise TypeError('Not the leader. Cannot change leader settings.')
194 if value is not None and not isinstance(value, six.string_types):
195 # We don't do implicit casting. This would cause simple
196 # types like integers to be read back as strings in subsequent
197 # hooks, and mutable types would require a lot of wrapping
198 # to ensure leader-set gets called when they are mutated.
199 raise ValueError('Only string values allowed')
200 hookenv.leader_set({key: value})
201
202 def __delitem__(self, key):
203 # Deleting a key and setting it to null is the same thing in
204 # Juju leadership settings.
205 self[key] = None
0206
=== added directory 'hooks/charmhelpers/contrib/amulet'
=== added file 'hooks/charmhelpers/contrib/amulet/__init__.py'
--- hooks/charmhelpers/contrib/amulet/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/amulet/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,13 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
014
=== added file 'hooks/charmhelpers/contrib/amulet/deployment.py'
--- hooks/charmhelpers/contrib/amulet/deployment.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/amulet/deployment.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,99 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import amulet
16import os
17import six
18
19
20class AmuletDeployment(object):
21 """Amulet deployment.
22
23 This class provides generic Amulet deployment and test runner
24 methods.
25 """
26
27 def __init__(self, series=None):
28 """Initialize the deployment environment."""
29 self.series = None
30
31 if series:
32 self.series = series
33 self.d = amulet.Deployment(series=self.series)
34 else:
35 self.d = amulet.Deployment()
36
37 def _add_services(self, this_service, other_services):
38 """Add services.
39
40 Add services to the deployment where this_service is the local charm
41 that we're testing and other_services are the other services that
42 are being used in the local amulet tests.
43 """
44 if this_service['name'] != os.path.basename(os.getcwd()):
45 s = this_service['name']
46 msg = "The charm's root directory name needs to be {}".format(s)
47 amulet.raise_status(amulet.FAIL, msg=msg)
48
49 if 'units' not in this_service:
50 this_service['units'] = 1
51
52 self.d.add(this_service['name'], units=this_service['units'],
53 constraints=this_service.get('constraints'),
54 storage=this_service.get('storage'))
55
56 for svc in other_services:
57 if 'location' in svc:
58 branch_location = svc['location']
59 elif self.series:
60 branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
61 else:
62 branch_location = None
63
64 if 'units' not in svc:
65 svc['units'] = 1
66
67 self.d.add(svc['name'], charm=branch_location, units=svc['units'],
68 constraints=svc.get('constraints'),
69 storage=svc.get('storage'))
70
71 def _add_relations(self, relations):
72 """Add all of the relations for the services."""
73 for k, v in six.iteritems(relations):
74 self.d.relate(k, v)
75
76 def _configure_services(self, configs):
77 """Configure all of the services."""
78 for service, config in six.iteritems(configs):
79 self.d.configure(service, config)
80
81 def _deploy(self):
82 """Deploy environment and wait for all hooks to finish executing."""
83 timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 900))
84 try:
85 self.d.setup(timeout=timeout)
86 self.d.sentry.wait(timeout=timeout)
87 except amulet.helpers.TimeoutError:
88 amulet.raise_status(
89 amulet.FAIL,
90 msg="Deployment timed out ({}s)".format(timeout)
91 )
92 except Exception:
93 raise
94
95 def run_tests(self):
96 """Run all of the methods that are prefixed with 'test_'."""
97 for test in dir(self):
98 if test.startswith('test_'):
99 getattr(self, test)()
0100
=== added file 'hooks/charmhelpers/contrib/amulet/utils.py'
--- hooks/charmhelpers/contrib/amulet/utils.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/amulet/utils.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,820 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import io
16import json
17import logging
18import os
19import re
20import socket
21import subprocess
22import sys
23import time
24import uuid
25
26import amulet
27import distro_info
28import six
29from six.moves import configparser
30if six.PY3:
31 from urllib import parse as urlparse
32else:
33 import urlparse
34
35
36class AmuletUtils(object):
37 """Amulet utilities.
38
39 This class provides common utility functions that are used by Amulet
40 tests.
41 """
42
43 def __init__(self, log_level=logging.ERROR):
44 self.log = self.get_logger(level=log_level)
45 self.ubuntu_releases = self.get_ubuntu_releases()
46
47 def get_logger(self, name="amulet-logger", level=logging.DEBUG):
48 """Get a logger object that will log to stdout."""
49 log = logging
50 logger = log.getLogger(name)
51 fmt = log.Formatter("%(asctime)s %(funcName)s "
52 "%(levelname)s: %(message)s")
53
54 handler = log.StreamHandler(stream=sys.stdout)
55 handler.setLevel(level)
56 handler.setFormatter(fmt)
57
58 logger.addHandler(handler)
59 logger.setLevel(level)
60
61 return logger
62
63 def valid_ip(self, ip):
64 if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
65 return True
66 else:
67 return False
68
69 def valid_url(self, url):
70 p = re.compile(
71 r'^(?:http|ftp)s?://'
72 r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
73 r'localhost|'
74 r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
75 r'(?::\d+)?'
76 r'(?:/?|[/?]\S+)$',
77 re.IGNORECASE)
78 if p.match(url):
79 return True
80 else:
81 return False
82
83 def get_ubuntu_release_from_sentry(self, sentry_unit):
84 """Get Ubuntu release codename from sentry unit.
85
86 :param sentry_unit: amulet sentry/service unit pointer
87 :returns: list of strings - release codename, failure message
88 """
89 msg = None
90 cmd = 'lsb_release -cs'
91 release, code = sentry_unit.ssh(cmd)
92 if code == 0:
93 self.log.debug('{} lsb_release: {}'.format(
94 sentry_unit.info['unit_name'], release))
95 else:
96 msg = ('{} `{}` returned {} '
97 '{}'.format(sentry_unit.info['unit_name'],
98 cmd, release, code))
99 if release not in self.ubuntu_releases:
100 msg = ("Release ({}) not found in Ubuntu releases "
101 "({})".format(release, self.ubuntu_releases))
102 return release, msg
103
104 def validate_services(self, commands):
105 """Validate that lists of commands succeed on service units. Can be
106 used to verify system services are running on the corresponding
107 service units.
108
109 :param commands: dict with sentry keys and arbitrary command list vals
110 :returns: None if successful, Failure string message otherwise
111 """
112 self.log.debug('Checking status of system services...')
113
114 # /!\ DEPRECATION WARNING (beisner):
115 # New and existing tests should be rewritten to use
116 # validate_services_by_name() as it is aware of init systems.
117 self.log.warn('DEPRECATION WARNING: use '
118 'validate_services_by_name instead of validate_services '
119 'due to init system differences.')
120
121 for k, v in six.iteritems(commands):
122 for cmd in v:
123 output, code = k.run(cmd)
124 self.log.debug('{} `{}` returned '
125 '{}'.format(k.info['unit_name'],
126 cmd, code))
127 if code != 0:
128 return "command `{}` returned {}".format(cmd, str(code))
129 return None
130
131 def validate_services_by_name(self, sentry_services):
132 """Validate system service status by service name, automatically
133 detecting init system based on Ubuntu release codename.
134
135 :param sentry_services: dict with sentry keys and svc list values
136 :returns: None if successful, Failure string message otherwise
137 """
138 self.log.debug('Checking status of system services...')
139
140 # Point at which systemd became a thing
141 systemd_switch = self.ubuntu_releases.index('vivid')
142
143 for sentry_unit, services_list in six.iteritems(sentry_services):
144 # Get lsb_release codename from unit
145 release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
146 if ret:
147 return ret
148
149 for service_name in services_list:
150 if (self.ubuntu_releases.index(release) >= systemd_switch or
151 service_name in ['rabbitmq-server', 'apache2',
152 'memcached']):
153 # init is systemd (or regular sysv)
154 cmd = 'sudo service {} status'.format(service_name)
155 output, code = sentry_unit.run(cmd)
156 service_running = code == 0
157 elif self.ubuntu_releases.index(release) < systemd_switch:
158 # init is upstart
159 cmd = 'sudo status {}'.format(service_name)
160 output, code = sentry_unit.run(cmd)
161 service_running = code == 0 and "start/running" in output
162
163 self.log.debug('{} `{}` returned '
164 '{}'.format(sentry_unit.info['unit_name'],
165 cmd, code))
166 if not service_running:
167 return u"command `{}` returned {} {}".format(
168 cmd, output, str(code))
169 return None
170
171 def _get_config(self, unit, filename):
172 """Get a ConfigParser object for parsing a unit's config file."""
173 file_contents = unit.file_contents(filename)
174
175 # NOTE(beisner): by default, ConfigParser does not handle options
176 # with no value, such as the flags used in the mysql my.cnf file.
177 # https://bugs.python.org/issue7005
178 config = configparser.ConfigParser(allow_no_value=True)
179 config.readfp(io.StringIO(file_contents))
180 return config
181
182 def validate_config_data(self, sentry_unit, config_file, section,
183 expected):
184 """Validate config file data.
185
186 Verify that the specified section of the config file contains
187 the expected option key:value pairs.
188
189 Compare expected dictionary data vs actual dictionary data.
190 The values in the 'expected' dictionary can be strings, bools, ints,
191 longs, or can be a function that evaluates a variable and returns a
192 bool.
193 """
194 self.log.debug('Validating config file data ({} in {} on {})'
195 '...'.format(section, config_file,
196 sentry_unit.info['unit_name']))
197 config = self._get_config(sentry_unit, config_file)
198
199 if section != 'DEFAULT' and not config.has_section(section):
200 return "section [{}] does not exist".format(section)
201
202 for k in expected.keys():
203 if not config.has_option(section, k):
204 return "section [{}] is missing option {}".format(section, k)
205
206 actual = config.get(section, k)
207 v = expected[k]
208 if (isinstance(v, six.string_types) or
209 isinstance(v, bool) or
210 isinstance(v, six.integer_types)):
211 # handle explicit values
212 if actual != v:
213 return "section [{}] {}:{} != expected {}:{}".format(
214 section, k, actual, k, expected[k])
215 # handle function pointers, such as not_null or valid_ip
216 elif not v(actual):
217 return "section [{}] {}:{} != expected {}:{}".format(
218 section, k, actual, k, expected[k])
219 return None
220
221 def _validate_dict_data(self, expected, actual):
222 """Validate dictionary data.
223
224 Compare expected dictionary data vs actual dictionary data.
225 The values in the 'expected' dictionary can be strings, bools, ints,
226 longs, or can be a function that evaluates a variable and returns a
227 bool.
228 """
229 self.log.debug('actual: {}'.format(repr(actual)))
230 self.log.debug('expected: {}'.format(repr(expected)))
231
232 for k, v in six.iteritems(expected):
233 if k in actual:
234 if (isinstance(v, six.string_types) or
235 isinstance(v, bool) or
236 isinstance(v, six.integer_types)):
237 # handle explicit values
238 if v != actual[k]:
239 return "{}:{}".format(k, actual[k])
240 # handle function pointers, such as not_null or valid_ip
241 elif not v(actual[k]):
242 return "{}:{}".format(k, actual[k])
243 else:
244 return "key '{}' does not exist".format(k)
245 return None
246
247 def validate_relation_data(self, sentry_unit, relation, expected):
248 """Validate actual relation data based on expected relation data."""
249 actual = sentry_unit.relation(relation[0], relation[1])
250 return self._validate_dict_data(expected, actual)
251
252 def _validate_list_data(self, expected, actual):
253 """Compare expected list vs actual list data."""
254 for e in expected:
255 if e not in actual:
256 return "expected item {} not found in actual list".format(e)
257 return None
258
259 def not_null(self, string):
260 if string is not None:
261 return True
262 else:
263 return False
264
265 def _get_file_mtime(self, sentry_unit, filename):
266 """Get last modification time of file."""
267 return sentry_unit.file_stat(filename)['mtime']
268
269 def _get_dir_mtime(self, sentry_unit, directory):
270 """Get last modification time of directory."""
271 return sentry_unit.directory_stat(directory)['mtime']
272
273 def _get_proc_start_time(self, sentry_unit, service, pgrep_full=None):
274 """Get start time of a process based on the last modification time
275 of the /proc/pid directory.
276
277 :sentry_unit: The sentry unit to check for the service on
278 :service: service name to look for in process table
279 :pgrep_full: [Deprecated] Use full command line search mode with pgrep
280 :returns: epoch time of service process start
281 :param commands: list of bash commands
282 :param sentry_units: list of sentry unit pointers
283 :returns: None if successful; Failure message otherwise
284 """
285 pid_list = self.get_process_id_list(
286 sentry_unit, service, pgrep_full=pgrep_full)
287 pid = pid_list[0]
288 proc_dir = '/proc/{}'.format(pid)
289 self.log.debug('Pid for {} on {}: {}'.format(
290 service, sentry_unit.info['unit_name'], pid))
291
292 return self._get_dir_mtime(sentry_unit, proc_dir)
293
294 def service_restarted(self, sentry_unit, service, filename,
295 pgrep_full=None, sleep_time=20):
296 """Check if service was restarted.
297
298 Compare a service's start time vs a file's last modification time
299 (such as a config file for that service) to determine if the service
300 has been restarted.
301 """
302 # /!\ DEPRECATION WARNING (beisner):
303 # This method is prone to races in that no before-time is known.
304 # Use validate_service_config_changed instead.
305
306 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
307 # used instead of pgrep. pgrep_full is still passed through to ensure
308 # deprecation WARNS. lp1474030
309 self.log.warn('DEPRECATION WARNING: use '
310 'validate_service_config_changed instead of '
311 'service_restarted due to known races.')
312
313 time.sleep(sleep_time)
314 if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
315 self._get_file_mtime(sentry_unit, filename)):
316 return True
317 else:
318 return False
319
320 def service_restarted_since(self, sentry_unit, mtime, service,
321 pgrep_full=None, sleep_time=20,
322 retry_count=30, retry_sleep_time=10):
323 """Check if service was been started after a given time.
324
325 Args:
326 sentry_unit (sentry): The sentry unit to check for the service on
327 mtime (float): The epoch time to check against
328 service (string): service name to look for in process table
329 pgrep_full: [Deprecated] Use full command line search mode with pgrep
330 sleep_time (int): Initial sleep time (s) before looking for file
331 retry_sleep_time (int): Time (s) to sleep between retries
332 retry_count (int): If file is not found, how many times to retry
333
334 Returns:
335 bool: True if service found and its start time it newer than mtime,
336 False if service is older than mtime or if service was
337 not found.
338 """
339 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
340 # used instead of pgrep. pgrep_full is still passed through to ensure
341 # deprecation WARNS. lp1474030
342
343 unit_name = sentry_unit.info['unit_name']
344 self.log.debug('Checking that %s service restarted since %s on '
345 '%s' % (service, mtime, unit_name))
346 time.sleep(sleep_time)
347 proc_start_time = None
348 tries = 0
349 while tries <= retry_count and not proc_start_time:
350 try:
351 proc_start_time = self._get_proc_start_time(sentry_unit,
352 service,
353 pgrep_full)
354 self.log.debug('Attempt {} to get {} proc start time on {} '
355 'OK'.format(tries, service, unit_name))
356 except IOError as e:
357 # NOTE(beisner) - race avoidance, proc may not exist yet.
358 # https://bugs.launchpad.net/charm-helpers/+bug/1474030
359 self.log.debug('Attempt {} to get {} proc start time on {} '
360 'failed\n{}'.format(tries, service,
361 unit_name, e))
362 time.sleep(retry_sleep_time)
363 tries += 1
364
365 if not proc_start_time:
366 self.log.warn('No proc start time found, assuming service did '
367 'not start')
368 return False
369 if proc_start_time >= mtime:
370 self.log.debug('Proc start time is newer than provided mtime'
371 '(%s >= %s) on %s (OK)' % (proc_start_time,
372 mtime, unit_name))
373 return True
374 else:
375 self.log.warn('Proc start time (%s) is older than provided mtime '
376 '(%s) on %s, service did not '
377 'restart' % (proc_start_time, mtime, unit_name))
378 return False
379
380 def config_updated_since(self, sentry_unit, filename, mtime,
381 sleep_time=20, retry_count=30,
382 retry_sleep_time=10):
383 """Check if file was modified after a given time.
384
385 Args:
386 sentry_unit (sentry): The sentry unit to check the file mtime on
387 filename (string): The file to check mtime of
388 mtime (float): The epoch time to check against
389 sleep_time (int): Initial sleep time (s) before looking for file
390 retry_sleep_time (int): Time (s) to sleep between retries
391 retry_count (int): If file is not found, how many times to retry
392
393 Returns:
394 bool: True if file was modified more recently than mtime, False if
395 file was modified before mtime, or if file not found.
396 """
397 unit_name = sentry_unit.info['unit_name']
398 self.log.debug('Checking that %s updated since %s on '
399 '%s' % (filename, mtime, unit_name))
400 time.sleep(sleep_time)
401 file_mtime = None
402 tries = 0
403 while tries <= retry_count and not file_mtime:
404 try:
405 file_mtime = self._get_file_mtime(sentry_unit, filename)
406 self.log.debug('Attempt {} to get {} file mtime on {} '
407 'OK'.format(tries, filename, unit_name))
408 except IOError as e:
409 # NOTE(beisner) - race avoidance, file may not exist yet.
410 # https://bugs.launchpad.net/charm-helpers/+bug/1474030
411 self.log.debug('Attempt {} to get {} file mtime on {} '
412 'failed\n{}'.format(tries, filename,
413 unit_name, e))
414 time.sleep(retry_sleep_time)
415 tries += 1
416
417 if not file_mtime:
418 self.log.warn('Could not determine file mtime, assuming '
419 'file does not exist')
420 return False
421
422 if file_mtime >= mtime:
423 self.log.debug('File mtime is newer than provided mtime '
424 '(%s >= %s) on %s (OK)' % (file_mtime,
425 mtime, unit_name))
426 return True
427 else:
428 self.log.warn('File mtime is older than provided mtime'
429 '(%s < on %s) on %s' % (file_mtime,
430 mtime, unit_name))
431 return False
432
433 def validate_service_config_changed(self, sentry_unit, mtime, service,
434 filename, pgrep_full=None,
435 sleep_time=20, retry_count=30,
436 retry_sleep_time=10):
437 """Check service and file were updated after mtime
438
439 Args:
440 sentry_unit (sentry): The sentry unit to check for the service on
441 mtime (float): The epoch time to check against
442 service (string): service name to look for in process table
443 filename (string): The file to check mtime of
444 pgrep_full: [Deprecated] Use full command line search mode with pgrep
445 sleep_time (int): Initial sleep in seconds to pass to test helpers
446 retry_count (int): If service is not found, how many times to retry
447 retry_sleep_time (int): Time in seconds to wait between retries
448
449 Typical Usage:
450 u = OpenStackAmuletUtils(ERROR)
451 ...
452 mtime = u.get_sentry_time(self.cinder_sentry)
453 self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
454 if not u.validate_service_config_changed(self.cinder_sentry,
455 mtime,
456 'cinder-api',
457 '/etc/cinder/cinder.conf')
458 amulet.raise_status(amulet.FAIL, msg='update failed')
459 Returns:
460 bool: True if both service and file where updated/restarted after
461 mtime, False if service is older than mtime or if service was
462 not found or if filename was modified before mtime.
463 """
464
465 # NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
466 # used instead of pgrep. pgrep_full is still passed through to ensure
467 # deprecation WARNS. lp1474030
468
469 service_restart = self.service_restarted_since(
470 sentry_unit, mtime,
471 service,
472 pgrep_full=pgrep_full,
473 sleep_time=sleep_time,
474 retry_count=retry_count,
475 retry_sleep_time=retry_sleep_time)
476
477 config_update = self.config_updated_since(
478 sentry_unit,
479 filename,
480 mtime,
481 sleep_time=sleep_time,
482 retry_count=retry_count,
483 retry_sleep_time=retry_sleep_time)
484
485 return service_restart and config_update
486
487 def get_sentry_time(self, sentry_unit):
488 """Return current epoch time on a sentry"""
489 cmd = "date +'%s'"
490 return float(sentry_unit.run(cmd)[0])
491
492 def relation_error(self, name, data):
493 return 'unexpected relation data in {} - {}'.format(name, data)
494
495 def endpoint_error(self, name, data):
496 return 'unexpected endpoint data in {} - {}'.format(name, data)
497
498 def get_ubuntu_releases(self):
499 """Return a list of all Ubuntu releases in order of release."""
500 _d = distro_info.UbuntuDistroInfo()
501 _release_list = _d.all
502 return _release_list
503
504 def file_to_url(self, file_rel_path):
505 """Convert a relative file path to a file URL."""
506 _abs_path = os.path.abspath(file_rel_path)
507 return urlparse.urlparse(_abs_path, scheme='file').geturl()
508
509 def check_commands_on_units(self, commands, sentry_units):
510 """Check that all commands in a list exit zero on all
511 sentry units in a list.
512
513 :param commands: list of bash commands
514 :param sentry_units: list of sentry unit pointers
515 :returns: None if successful; Failure message otherwise
516 """
517 self.log.debug('Checking exit codes for {} commands on {} '
518 'sentry units...'.format(len(commands),
519 len(sentry_units)))
520 for sentry_unit in sentry_units:
521 for cmd in commands:
522 output, code = sentry_unit.run(cmd)
523 if code == 0:
524 self.log.debug('{} `{}` returned {} '
525 '(OK)'.format(sentry_unit.info['unit_name'],
526 cmd, code))
527 else:
528 return ('{} `{}` returned {} '
529 '{}'.format(sentry_unit.info['unit_name'],
530 cmd, code, output))
531 return None
532
533 def get_process_id_list(self, sentry_unit, process_name,
534 expect_success=True, pgrep_full=False):
535 """Get a list of process ID(s) from a single sentry juju unit
536 for a single process name.
537
538 :param sentry_unit: Amulet sentry instance (juju unit)
539 :param process_name: Process name
540 :param expect_success: If False, expect the PID to be missing,
541 raise if it is present.
542 :returns: List of process IDs
543 """
544 if pgrep_full:
545 cmd = 'pgrep -f "{}"'.format(process_name)
546 else:
547 cmd = 'pidof -x "{}"'.format(process_name)
548 if not expect_success:
549 cmd += " || exit 0 && exit 1"
550 output, code = sentry_unit.run(cmd)
551 if code != 0:
552 msg = ('{} `{}` returned {} '
553 '{}'.format(sentry_unit.info['unit_name'],
554 cmd, code, output))
555 amulet.raise_status(amulet.FAIL, msg=msg)
556 return str(output).split()
557
558 def get_unit_process_ids(
559 self, unit_processes, expect_success=True, pgrep_full=False):
560 """Construct a dict containing unit sentries, process names, and
561 process IDs.
562
563 :param unit_processes: A dictionary of Amulet sentry instance
564 to list of process names.
565 :param expect_success: if False expect the processes to not be
566 running, raise if they are.
567 :returns: Dictionary of Amulet sentry instance to dictionary
568 of process names to PIDs.
569 """
570 pid_dict = {}
571 for sentry_unit, process_list in six.iteritems(unit_processes):
572 pid_dict[sentry_unit] = {}
573 for process in process_list:
574 pids = self.get_process_id_list(
575 sentry_unit, process, expect_success=expect_success,
576 pgrep_full=pgrep_full)
577 pid_dict[sentry_unit].update({process: pids})
578 return pid_dict
579
580 def validate_unit_process_ids(self, expected, actual):
581 """Validate process id quantities for services on units."""
582 self.log.debug('Checking units for running processes...')
583 self.log.debug('Expected PIDs: {}'.format(expected))
584 self.log.debug('Actual PIDs: {}'.format(actual))
585
586 if len(actual) != len(expected):
587 return ('Unit count mismatch. expected, actual: {}, '
588 '{} '.format(len(expected), len(actual)))
589
590 for (e_sentry, e_proc_names) in six.iteritems(expected):
591 e_sentry_name = e_sentry.info['unit_name']
592 if e_sentry in actual.keys():
593 a_proc_names = actual[e_sentry]
594 else:
595 return ('Expected sentry ({}) not found in actual dict data.'
596 '{}'.format(e_sentry_name, e_sentry))
597
598 if len(e_proc_names.keys()) != len(a_proc_names.keys()):
599 return ('Process name count mismatch. expected, actual: {}, '
600 '{}'.format(len(expected), len(actual)))
601
602 for (e_proc_name, e_pids), (a_proc_name, a_pids) in \
603 zip(e_proc_names.items(), a_proc_names.items()):
604 if e_proc_name != a_proc_name:
605 return ('Process name mismatch. expected, actual: {}, '
606 '{}'.format(e_proc_name, a_proc_name))
607
608 a_pids_length = len(a_pids)
609 fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
610 '{}, {} ({})'.format(e_sentry_name, e_proc_name,
611 e_pids, a_pids_length,
612 a_pids))
613
614 # If expected is a list, ensure at least one PID quantity match
615 if isinstance(e_pids, list) and \
616 a_pids_length not in e_pids:
617 return fail_msg
618 # If expected is not bool and not list,
619 # ensure PID quantities match
620 elif not isinstance(e_pids, bool) and \
621 not isinstance(e_pids, list) and \
622 a_pids_length != e_pids:
623 return fail_msg
624 # If expected is bool True, ensure 1 or more PIDs exist
625 elif isinstance(e_pids, bool) and \
626 e_pids is True and a_pids_length < 1:
627 return fail_msg
628 # If expected is bool False, ensure 0 PIDs exist
629 elif isinstance(e_pids, bool) and \
630 e_pids is False and a_pids_length != 0:
631 return fail_msg
632 else:
633 self.log.debug('PID check OK: {} {} {}: '
634 '{}'.format(e_sentry_name, e_proc_name,
635 e_pids, a_pids))
636 return None
637
638 def validate_list_of_identical_dicts(self, list_of_dicts):
639 """Check that all dicts within a list are identical."""
640 hashes = []
641 for _dict in list_of_dicts:
642 hashes.append(hash(frozenset(_dict.items())))
643
644 self.log.debug('Hashes: {}'.format(hashes))
645 if len(set(hashes)) == 1:
646 self.log.debug('Dicts within list are identical')
647 else:
648 return 'Dicts within list are not identical'
649
650 return None
651
652 def validate_sectionless_conf(self, file_contents, expected):
653 """A crude conf parser. Useful to inspect configuration files which
654 do not have section headers (as would be necessary in order to use
655 the configparser). Such as openstack-dashboard or rabbitmq confs."""
656 for line in file_contents.split('\n'):
657 if '=' in line:
658 args = line.split('=')
659 if len(args) <= 1:
660 continue
661 key = args[0].strip()
662 value = args[1].strip()
663 if key in expected.keys():
664 if expected[key] != value:
665 msg = ('Config mismatch. Expected, actual: {}, '
666 '{}'.format(expected[key], value))
667 amulet.raise_status(amulet.FAIL, msg=msg)
668
669 def get_unit_hostnames(self, units):
670 """Return a dict of juju unit names to hostnames."""
671 host_names = {}
672 for unit in units:
673 host_names[unit.info['unit_name']] = \
674 str(unit.file_contents('/etc/hostname').strip())
675 self.log.debug('Unit host names: {}'.format(host_names))
676 return host_names
677
678 def run_cmd_unit(self, sentry_unit, cmd):
679 """Run a command on a unit, return the output and exit code."""
680 output, code = sentry_unit.run(cmd)
681 if code == 0:
682 self.log.debug('{} `{}` command returned {} '
683 '(OK)'.format(sentry_unit.info['unit_name'],
684 cmd, code))
685 else:
686 msg = ('{} `{}` command returned {} '
687 '{}'.format(sentry_unit.info['unit_name'],
688 cmd, code, output))
689 amulet.raise_status(amulet.FAIL, msg=msg)
690 return str(output), code
691
692 def file_exists_on_unit(self, sentry_unit, file_name):
693 """Check if a file exists on a unit."""
694 try:
695 sentry_unit.file_stat(file_name)
696 return True
697 except IOError:
698 return False
699 except Exception as e:
700 msg = 'Error checking file {}: {}'.format(file_name, e)
701 amulet.raise_status(amulet.FAIL, msg=msg)
702
703 def file_contents_safe(self, sentry_unit, file_name,
704 max_wait=60, fatal=False):
705 """Get file contents from a sentry unit. Wrap amulet file_contents
706 with retry logic to address races where a file checks as existing,
707 but no longer exists by the time file_contents is called.
708 Return None if file not found. Optionally raise if fatal is True."""
709 unit_name = sentry_unit.info['unit_name']
710 file_contents = False
711 tries = 0
712 while not file_contents and tries < (max_wait / 4):
713 try:
714 file_contents = sentry_unit.file_contents(file_name)
715 except IOError:
716 self.log.debug('Attempt {} to open file {} from {} '
717 'failed'.format(tries, file_name,
718 unit_name))
719 time.sleep(4)
720 tries += 1
721
722 if file_contents:
723 return file_contents
724 elif not fatal:
725 return None
726 elif fatal:
727 msg = 'Failed to get file contents from unit.'
728 amulet.raise_status(amulet.FAIL, msg)
729
730 def port_knock_tcp(self, host="localhost", port=22, timeout=15):
731 """Open a TCP socket to check for a listening sevice on a host.
732
733 :param host: host name or IP address, default to localhost
734 :param port: TCP port number, default to 22
735 :param timeout: Connect timeout, default to 15 seconds
736 :returns: True if successful, False if connect failed
737 """
738
739 # Resolve host name if possible
740 try:
741 connect_host = socket.gethostbyname(host)
742 host_human = "{} ({})".format(connect_host, host)
743 except socket.error as e:
744 self.log.warn('Unable to resolve address: '
745 '{} ({}) Trying anyway!'.format(host, e))
746 connect_host = host
747 host_human = connect_host
748
749 # Attempt socket connection
750 try:
751 knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
752 knock.settimeout(timeout)
753 knock.connect((connect_host, port))
754 knock.close()
755 self.log.debug('Socket connect OK for host '
756 '{} on port {}.'.format(host_human, port))
757 return True
758 except socket.error as e:
759 self.log.debug('Socket connect FAIL for'
760 ' {} port {} ({})'.format(host_human, port, e))
761 return False
762
763 def port_knock_units(self, sentry_units, port=22,
764 timeout=15, expect_success=True):
765 """Open a TCP socket to check for a listening sevice on each
766 listed juju unit.
767
768 :param sentry_units: list of sentry unit pointers
769 :param port: TCP port number, default to 22
770 :param timeout: Connect timeout, default to 15 seconds
771 :expect_success: True by default, set False to invert logic
772 :returns: None if successful, Failure message otherwise
773 """
774 for unit in sentry_units:
775 host = unit.info['public-address']
776 connected = self.port_knock_tcp(host, port, timeout)
777 if not connected and expect_success:
778 return 'Socket connect failed.'
779 elif connected and not expect_success:
780 return 'Socket connected unexpectedly.'
781
782 def get_uuid_epoch_stamp(self):
783 """Returns a stamp string based on uuid4 and epoch time. Useful in
784 generating test messages which need to be unique-ish."""
785 return '[{}-{}]'.format(uuid.uuid4(), time.time())
786
787 # amulet juju action helpers:
788 def run_action(self, unit_sentry, action,
789 _check_output=subprocess.check_output,
790 params=None):
791 """Translate to amulet's built in run_action(). Deprecated.
792
793 Run the named action on a given unit sentry.
794
795 params a dict of parameters to use
796 _check_output parameter is no longer used
797
798 @return action_id.
799 """
800 self.log.warn('charmhelpers.contrib.amulet.utils.run_action has been '
801 'deprecated for amulet.run_action')
802 return unit_sentry.run_action(action, action_args=params)
803
804 def wait_on_action(self, action_id, _check_output=subprocess.check_output):
805 """Wait for a given action, returning if it completed or not.
806
807 action_id a string action uuid
808 _check_output parameter is no longer used
809 """
810 data = amulet.actions.get_action_output(action_id, full_output=True)
811 return data.get(u"status") == "completed"
812
813 def status_get(self, unit):
814 """Return the current service status of this unit."""
815 raw_status, return_code = unit.run(
816 "status-get --format=json --include-data")
817 if return_code != 0:
818 return ("unknown", "")
819 status = json.loads(raw_status)
820 return (status["status"], status["message"])
0821
=== modified file 'hooks/charmhelpers/contrib/ansible/__init__.py'
--- hooks/charmhelpers/contrib/ansible/__init__.py 2016-12-20 14:35:00 +0000
+++ hooks/charmhelpers/contrib/ansible/__init__.py 2021-05-12 04:07:51 +0000
@@ -16,90 +16,107 @@
16#16#
17# Authors:17# Authors:
18# Charm Helpers Developers <juju@lists.ubuntu.com>18# Charm Helpers Developers <juju@lists.ubuntu.com>
19"""Charm Helpers ansible - declare the state of your machines.19"""
2020The ansible package enables you to easily use the configuration management
21This helper enables you to declare your machine state, rather than21tool `Ansible`_ to setup and configure your charm. All of your charm
22program it procedurally (and have to test each change to your procedures).22configuration options and relation-data are available as regular Ansible
23Your install hook can be as simple as::23variables which can be used in your playbooks and templates.
2424
25 {{{25.. _Ansible: https://www.ansible.com/
26 import charmhelpers.contrib.ansible26
2727Usage
2828=====
29
30Here is an example directory structure for a charm to get you started::
31
32 charm-ansible-example/
33 |-- ansible
34 | |-- playbook.yaml
35 | `-- templates
36 | `-- example.j2
37 |-- config.yaml
38 |-- copyright
39 |-- icon.svg
40 |-- layer.yaml
41 |-- metadata.yaml
42 |-- reactive
43 | `-- example.py
44 |-- README.md
45
46Running a playbook called ``playbook.yaml`` when the ``install`` hook is run
47can be as simple as::
48
49 from charmhelpers.contrib import ansible
50 from charms.reactive import hook
51
52 @hook('install')
29 def install():53 def install():
30 charmhelpers.contrib.ansible.install_ansible_support()54 ansible.install_ansible_support()
31 charmhelpers.contrib.ansible.apply_playbook('playbooks/install.yaml')55 ansible.apply_playbook('ansible/playbook.yaml')
32 }}}56
3357Here is an example playbook that uses the ``template`` module to template the
34and won't need to change (nor will its tests) when you change the machine58file ``example.j2`` to the charm host and then uses the ``debug`` module to
35state.59print out all the host and Juju variables that you can use in your playbooks.
3660Note that you must target ``localhost`` as the playbook is run locally on the
37All of your juju config and relation-data are available as template61charm host::
38variables within your playbooks and templates. An install playbook looks62
39something like::
40
41 {{{
42 ---63 ---
43 - hosts: localhost64 - hosts: localhost
44 user: root
45
46 tasks:65 tasks:
47 - name: Add private repositories.66 - name: Template a file
48 template:67 template:
49 src: ../templates/private-repositories.list.jinja268 src: templates/example.j2
50 dest: /etc/apt/sources.list.d/private.list69 dest: /tmp/example.j2
5170
52 - name: Update the cache.71 - name: Print all variables available to Ansible
53 apt: update_cache=yes72 debug:
5473 var: vars
55 - name: Install dependencies.74
56 apt: pkg={{ item }}75Read more online about `playbooks`_ and standard Ansible `modules`_.
57 with_items:76
58 - python-mimeparse77.. _playbooks: https://docs.ansible.com/ansible/latest/user_guide/playbooks.html
59 - python-webob78.. _modules: https://docs.ansible.com/ansible/latest/user_guide/modules.html
60 - sunburnt79
6180A further feature of the Ansible hooks is to provide a light weight "action"
62 - name: Setup groups.
63 group: name={{ item.name }} gid={{ item.gid }}
64 with_items:
65 - { name: 'deploy_user', gid: 1800 }
66 - { name: 'service_user', gid: 1500 }
67
68 ...
69 }}}
70
71Read more online about `playbooks`_ and standard ansible `modules`_.
72
73.. _playbooks: http://www.ansibleworks.com/docs/playbooks.html
74.. _modules: http://www.ansibleworks.com/docs/modules.html
75
76A further feature os the ansible hooks is to provide a light weight "action"
77scripting tool. This is a decorator that you apply to a function, and that81scripting tool. This is a decorator that you apply to a function, and that
78function can now receive cli args, and can pass extra args to the playbook.82function can now receive cli args, and can pass extra args to the playbook::
7983
80e.g.84 @hooks.action()
8185 def some_action(amount, force="False"):
8286 "Usage: some-action AMOUNT [force=True]" # <-- shown on error
83@hooks.action()87 # process the arguments
84def some_action(amount, force="False"):88 # do some calls
85 "Usage: some-action AMOUNT [force=True]" # <-- shown on error89 # return extra-vars to be passed to ansible-playbook
86 # process the arguments90 return {
87 # do some calls91 'amount': int(amount),
88 # return extra-vars to be passed to ansible-playbook92 'type': force,
89 return {93 }
90 'amount': int(amount),
91 'type': force,
92 }
9394
94You can now create a symlink to hooks.py that can be invoked like a hook, but95You can now create a symlink to hooks.py that can be invoked like a hook, but
95with cli params:96with cli params::
9697
97# link actions/some-action to hooks/hooks.py98 # link actions/some-action to hooks/hooks.py
9899
99actions/some-action amount=10 force=true100 actions/some-action amount=10 force=true
101
102Install Ansible via pip
103=======================
104
105If you want to install a specific version of Ansible via pip instead of
106``install_ansible_support`` which uses APT, consider using the layer options
107of `layer-basic`_ to install Ansible in a virtualenv::
108
109 options:
110 basic:
111 python_packages: ['ansible==2.9.0']
112 include_system_packages: true
113 use_venv: true
114
115.. _layer-basic: https://charmsreactive.readthedocs.io/en/latest/layer-basic.html#layer-configuration
100116
101"""117"""
102import os118import os
119import json
103import stat120import stat
104import subprocess121import subprocess
105import functools122import functools
@@ -117,27 +134,63 @@
117ansible_vars_path = '/etc/ansible/host_vars/localhost'134ansible_vars_path = '/etc/ansible/host_vars/localhost'
118135
119136
120def install_ansible_support(from_ppa=True, ppa_location='ppa:rquillo/ansible'):137def install_ansible_support(from_ppa=True, ppa_location='ppa:ansible/ansible'):
121 """Installs the ansible package.138 """Installs Ansible via APT.
122139
123 By default it is installed from the `PPA`_ linked from140 By default this installs Ansible from the `PPA`_ linked from
124 the ansible `website`_ or from a ppa specified by a charm config..141 the Ansible `website`_ or from a PPA set in ``ppa_location``.
125142
126 .. _PPA: https://launchpad.net/~rquillo/+archive/ansible143 .. _PPA: https://launchpad.net/~ansible/+archive/ubuntu/ansible
127 .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu144 .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu
128145
129 If from_ppa is empty, you must ensure that the package is available146 If ``from_ppa`` is ``False``, then Ansible will be installed from
130 from a configured repository.147 Ubuntu's Universe repositories.
131 """148 """
132 if from_ppa:149 if from_ppa:
133 charmhelpers.fetch.add_source(ppa_location)150 charmhelpers.fetch.add_source(ppa_location)
134 charmhelpers.fetch.apt_update(fatal=True)151 charmhelpers.fetch.apt_update(fatal=True)
135 charmhelpers.fetch.apt_install('ansible')152 charmhelpers.fetch.apt_install('ansible')
136 with open(ansible_hosts_path, 'w+') as hosts_file:153 with open(ansible_hosts_path, 'w+') as hosts_file:
137 hosts_file.write('localhost ansible_connection=local')154 hosts_file.write('localhost ansible_connection=local ansible_remote_tmp=/root/.ansible/tmp')
138155
139156
140def apply_playbook(playbook, tags=None, extra_vars=None):157def apply_playbook(playbook, tags=None, extra_vars=None):
158 """Run a playbook.
159
160 This helper runs a playbook with juju state variables as context,
161 therefore variables set in application config can be used directly.
162 List of tags (--tags) and dictionary with extra_vars (--extra-vars)
163 can be passed as additional parameters.
164
165 Read more about playbook `_variables`_ online.
166
167 .. _variables: https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html
168
169 Example::
170
171 # Run ansible/playbook.yaml with tag install and pass extra
172 # variables var_a and var_b
173 apply_playbook(
174 playbook='ansible/playbook.yaml',
175 tags=['install'],
176 extra_vars={'var_a': 'val_a', 'var_b': 'val_b'}
177 )
178
179 # Run ansible/playbook.yaml with tag config and extra variable nested,
180 # which is passed as json and can be used as dictionary in playbook
181 apply_playbook(
182 playbook='ansible/playbook.yaml',
183 tags=['config'],
184 extra_vars={'nested': {'a': 'value1', 'b': 'value2'}}
185 )
186
187 # Custom config file can be passed within extra_vars
188 apply_playbook(
189 playbook='ansible/playbook.yaml',
190 extra_vars="@some_file.json"
191 )
192
193 """
141 tags = tags or []194 tags = tags or []
142 tags = ",".join(tags)195 tags = ",".join(tags)
143 charmhelpers.contrib.templating.contexts.juju_state_to_yaml(196 charmhelpers.contrib.templating.contexts.juju_state_to_yaml(
@@ -146,6 +199,9 @@
146199
147 # we want ansible's log output to be unbuffered200 # we want ansible's log output to be unbuffered
148 env = os.environ.copy()201 env = os.environ.copy()
202 proxy_settings = charmhelpers.core.hookenv.env_proxy_settings()
203 if proxy_settings:
204 env.update(proxy_settings)
149 env['PYTHONUNBUFFERED'] = "1"205 env['PYTHONUNBUFFERED'] = "1"
150 call = [206 call = [
151 'ansible-playbook',207 'ansible-playbook',
@@ -156,8 +212,7 @@
156 if tags:212 if tags:
157 call.extend(['--tags', '{}'.format(tags)])213 call.extend(['--tags', '{}'.format(tags)])
158 if extra_vars:214 if extra_vars:
159 extra = ["%s=%s" % (k, v) for k, v in extra_vars.items()]215 call.extend(['--extra-vars', json.dumps(extra_vars)])
160 call.extend(['--extra-vars', " ".join(extra)])
161 subprocess.check_call(call, env=env)216 subprocess.check_call(call, env=env)
162217
163218
@@ -170,7 +225,7 @@
170225
171 Example::226 Example::
172227
173 hooks = AnsibleHooks(playbook_path='playbooks/my_machine_state.yaml')228 hooks = AnsibleHooks(playbook_path='ansible/my_machine_state.yaml')
174229
175 # All the tasks within my_machine_state.yaml tagged with 'install'230 # All the tasks within my_machine_state.yaml tagged with 'install'
176 # will be run automatically after do_custom_work()231 # will be run automatically after do_custom_work()
@@ -188,13 +243,12 @@
188 # the hooks which are handled by ansible-only and they'll be registered243 # the hooks which are handled by ansible-only and they'll be registered
189 # for you:244 # for you:
190 # hooks = AnsibleHooks(245 # hooks = AnsibleHooks(
191 # 'playbooks/my_machine_state.yaml',246 # 'ansible/my_machine_state.yaml',
192 # default_hooks=['config-changed', 'start', 'stop'])247 # default_hooks=['config-changed', 'start', 'stop'])
193248
194 if __name__ == "__main__":249 if __name__ == "__main__":
195 # execute a hook based on the name the program is called by250 # execute a hook based on the name the program is called by
196 hooks.execute(sys.argv)251 hooks.execute(sys.argv)
197
198 """252 """
199253
200 def __init__(self, playbook_path, default_hooks=None):254 def __init__(self, playbook_path, default_hooks=None):
201255
=== added directory 'hooks/charmhelpers/contrib/benchmark'
=== added file 'hooks/charmhelpers/contrib/benchmark/__init__.py'
--- hooks/charmhelpers/contrib/benchmark/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/benchmark/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,124 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import subprocess
16import time
17import os
18from distutils.spawn import find_executable
19
20from charmhelpers.core.hookenv import (
21 in_relation_hook,
22 relation_ids,
23 relation_set,
24 relation_get,
25)
26
27
28def action_set(key, val):
29 if find_executable('action-set'):
30 action_cmd = ['action-set']
31
32 if isinstance(val, dict):
33 for k, v in iter(val.items()):
34 action_set('%s.%s' % (key, k), v)
35 return True
36
37 action_cmd.append('%s=%s' % (key, val))
38 subprocess.check_call(action_cmd)
39 return True
40 return False
41
42
43class Benchmark():
44 """
45 Helper class for the `benchmark` interface.
46
47 :param list actions: Define the actions that are also benchmarks
48
49 From inside the benchmark-relation-changed hook, you would
50 Benchmark(['memory', 'cpu', 'disk', 'smoke', 'custom'])
51
52 Examples:
53
54 siege = Benchmark(['siege'])
55 siege.start()
56 [... run siege ...]
57 # The higher the score, the better the benchmark
58 siege.set_composite_score(16.70, 'trans/sec', 'desc')
59 siege.finish()
60
61
62 """
63
64 BENCHMARK_CONF = '/etc/benchmark.conf' # Replaced in testing
65
66 required_keys = [
67 'hostname',
68 'port',
69 'graphite_port',
70 'graphite_endpoint',
71 'api_port'
72 ]
73
74 def __init__(self, benchmarks=None):
75 if in_relation_hook():
76 if benchmarks is not None:
77 for rid in sorted(relation_ids('benchmark')):
78 relation_set(relation_id=rid, relation_settings={
79 'benchmarks': ",".join(benchmarks)
80 })
81
82 # Check the relation data
83 config = {}
84 for key in self.required_keys:
85 val = relation_get(key)
86 if val is not None:
87 config[key] = val
88 else:
89 # We don't have all of the required keys
90 config = {}
91 break
92
93 if len(config):
94 with open(self.BENCHMARK_CONF, 'w') as f:
95 for key, val in iter(config.items()):
96 f.write("%s=%s\n" % (key, val))
97
98 @staticmethod
99 def start():
100 action_set('meta.start', time.strftime('%Y-%m-%dT%H:%M:%SZ'))
101
102 """
103 If the collectd charm is also installed, tell it to send a snapshot
104 of the current profile data.
105 """
106 COLLECT_PROFILE_DATA = '/usr/local/bin/collect-profile-data'
107 if os.path.exists(COLLECT_PROFILE_DATA):
108 subprocess.check_output([COLLECT_PROFILE_DATA])
109
110 @staticmethod
111 def finish():
112 action_set('meta.stop', time.strftime('%Y-%m-%dT%H:%M:%SZ'))
113
114 @staticmethod
115 def set_composite_score(value, units, direction='asc'):
116 """
117 Set the composite score for a benchmark run. This is a single number
118 representative of the benchmark results. This could be the most
119 important metric, or an amalgamation of metric scores.
120 """
121 return action_set(
122 "meta.composite",
123 {'value': value, 'units': units, 'direction': direction}
124 )
0125
=== added directory 'hooks/charmhelpers/contrib/charmhelpers'
=== added file 'hooks/charmhelpers/contrib/charmhelpers/IMPORT'
--- hooks/charmhelpers/contrib/charmhelpers/IMPORT 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/charmhelpers/IMPORT 2021-05-12 04:07:51 +0000
@@ -0,0 +1,4 @@
1Source lp:charm-tools/trunk
2
3charm-tools/helpers/python/charmhelpers/__init__.py -> charmhelpers/charmhelpers/contrib/charmhelpers/__init__.py
4charm-tools/helpers/python/charmhelpers/tests/test_charmhelpers.py -> charmhelpers/tests/contrib/charmhelpers/test_charmhelpers.py
05
=== added file 'hooks/charmhelpers/contrib/charmhelpers/__init__.py'
--- hooks/charmhelpers/contrib/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/charmhelpers/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,203 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import warnings
16warnings.warn("contrib.charmhelpers is deprecated", DeprecationWarning) # noqa
17
18import operator
19import tempfile
20import time
21import yaml
22import subprocess
23
24import six
25if six.PY3:
26 from urllib.request import urlopen
27 from urllib.error import (HTTPError, URLError)
28else:
29 from urllib2 import (urlopen, HTTPError, URLError)
30
31"""Helper functions for writing Juju charms in Python."""
32
33__metaclass__ = type
34__all__ = [
35 # 'get_config', # core.hookenv.config()
36 # 'log', # core.hookenv.log()
37 # 'log_entry', # core.hookenv.log()
38 # 'log_exit', # core.hookenv.log()
39 # 'relation_get', # core.hookenv.relation_get()
40 # 'relation_set', # core.hookenv.relation_set()
41 # 'relation_ids', # core.hookenv.relation_ids()
42 # 'relation_list', # core.hookenv.relation_units()
43 # 'config_get', # core.hookenv.config()
44 # 'unit_get', # core.hookenv.unit_get()
45 # 'open_port', # core.hookenv.open_port()
46 # 'close_port', # core.hookenv.close_port()
47 # 'service_control', # core.host.service()
48 'unit_info', # client-side, NOT IMPLEMENTED
49 'wait_for_machine', # client-side, NOT IMPLEMENTED
50 'wait_for_page_contents', # client-side, NOT IMPLEMENTED
51 'wait_for_relation', # client-side, NOT IMPLEMENTED
52 'wait_for_unit', # client-side, NOT IMPLEMENTED
53]
54
55
56SLEEP_AMOUNT = 0.1
57
58
59# We create a juju_status Command here because it makes testing much,
60# much easier.
61def juju_status():
62 subprocess.check_call(['juju', 'status'])
63
64# re-implemented as charmhelpers.fetch.configure_sources()
65# def configure_source(update=False):
66# source = config_get('source')
67# if ((source.startswith('ppa:') or
68# source.startswith('cloud:') or
69# source.startswith('http:'))):
70# run('add-apt-repository', source)
71# if source.startswith("http:"):
72# run('apt-key', 'import', config_get('key'))
73# if update:
74# run('apt-get', 'update')
75
76
77# DEPRECATED: client-side only
78def make_charm_config_file(charm_config):
79 charm_config_file = tempfile.NamedTemporaryFile(mode='w+')
80 charm_config_file.write(yaml.dump(charm_config))
81 charm_config_file.flush()
82 # The NamedTemporaryFile instance is returned instead of just the name
83 # because we want to take advantage of garbage collection-triggered
84 # deletion of the temp file when it goes out of scope in the caller.
85 return charm_config_file
86
87
88# DEPRECATED: client-side only
89def unit_info(service_name, item_name, data=None, unit=None):
90 if data is None:
91 data = yaml.safe_load(juju_status())
92 service = data['services'].get(service_name)
93 if service is None:
94 # XXX 2012-02-08 gmb:
95 # This allows us to cope with the race condition that we
96 # have between deploying a service and having it come up in
97 # `juju status`. We could probably do with cleaning it up so
98 # that it fails a bit more noisily after a while.
99 return ''
100 units = service['units']
101 if unit is not None:
102 item = units[unit][item_name]
103 else:
104 # It might seem odd to sort the units here, but we do it to
105 # ensure that when no unit is specified, the first unit for the
106 # service (or at least the one with the lowest number) is the
107 # one whose data gets returned.
108 sorted_unit_names = sorted(units.keys())
109 item = units[sorted_unit_names[0]][item_name]
110 return item
111
112
113# DEPRECATED: client-side only
114def get_machine_data():
115 return yaml.safe_load(juju_status())['machines']
116
117
118# DEPRECATED: client-side only
119def wait_for_machine(num_machines=1, timeout=300):
120 """Wait `timeout` seconds for `num_machines` machines to come up.
121
122 This wait_for... function can be called by other wait_for functions
123 whose timeouts might be too short in situations where only a bare
124 Juju setup has been bootstrapped.
125
126 :return: A tuple of (num_machines, time_taken). This is used for
127 testing.
128 """
129 # You may think this is a hack, and you'd be right. The easiest way
130 # to tell what environment we're working in (LXC vs EC2) is to check
131 # the dns-name of the first machine. If it's localhost we're in LXC
132 # and we can just return here.
133 if get_machine_data()[0]['dns-name'] == 'localhost':
134 return 1, 0
135 start_time = time.time()
136 while True:
137 # Drop the first machine, since it's the Zookeeper and that's
138 # not a machine that we need to wait for. This will only work
139 # for EC2 environments, which is why we return early above if
140 # we're in LXC.
141 machine_data = get_machine_data()
142 non_zookeeper_machines = [
143 machine_data[key] for key in list(machine_data.keys())[1:]]
144 if len(non_zookeeper_machines) >= num_machines:
145 all_machines_running = True
146 for machine in non_zookeeper_machines:
147 if machine.get('instance-state') != 'running':
148 all_machines_running = False
149 break
150 if all_machines_running:
151 break
152 if time.time() - start_time >= timeout:
153 raise RuntimeError('timeout waiting for service to start')
154 time.sleep(SLEEP_AMOUNT)
155 return num_machines, time.time() - start_time
156
157
158# DEPRECATED: client-side only
159def wait_for_unit(service_name, timeout=480):
160 """Wait `timeout` seconds for a given service name to come up."""
161 wait_for_machine(num_machines=1)
162 start_time = time.time()
163 while True:
164 state = unit_info(service_name, 'agent-state')
165 if 'error' in state or state == 'started':
166 break
167 if time.time() - start_time >= timeout:
168 raise RuntimeError('timeout waiting for service to start')
169 time.sleep(SLEEP_AMOUNT)
170 if state != 'started':
171 raise RuntimeError('unit did not start, agent-state: ' + state)
172
173
174# DEPRECATED: client-side only
175def wait_for_relation(service_name, relation_name, timeout=120):
176 """Wait `timeout` seconds for a given relation to come up."""
177 start_time = time.time()
178 while True:
179 relation = unit_info(service_name, 'relations').get(relation_name)
180 if relation is not None and relation['state'] == 'up':
181 break
182 if time.time() - start_time >= timeout:
183 raise RuntimeError('timeout waiting for relation to be up')
184 time.sleep(SLEEP_AMOUNT)
185
186
187# DEPRECATED: client-side only
188def wait_for_page_contents(url, contents, timeout=120, validate=None):
189 if validate is None:
190 validate = operator.contains
191 start_time = time.time()
192 while True:
193 try:
194 stream = urlopen(url)
195 except (HTTPError, URLError):
196 pass
197 else:
198 page = stream.read()
199 if validate(page, contents):
200 return page
201 if time.time() - start_time >= timeout:
202 raise RuntimeError('timeout waiting for contents of ' + url)
203 time.sleep(SLEEP_AMOUNT)
0204
=== added file 'hooks/charmhelpers/contrib/charmsupport/IMPORT'
--- hooks/charmhelpers/contrib/charmsupport/IMPORT 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/charmsupport/IMPORT 2021-05-12 04:07:51 +0000
@@ -0,0 +1,14 @@
1Source: lp:charmsupport/trunk
2
3charmsupport/charmsupport/execd.py -> charm-helpers/charmhelpers/contrib/charmsupport/execd.py
4charmsupport/charmsupport/hookenv.py -> charm-helpers/charmhelpers/contrib/charmsupport/hookenv.py
5charmsupport/charmsupport/host.py -> charm-helpers/charmhelpers/contrib/charmsupport/host.py
6charmsupport/charmsupport/nrpe.py -> charm-helpers/charmhelpers/contrib/charmsupport/nrpe.py
7charmsupport/charmsupport/volumes.py -> charm-helpers/charmhelpers/contrib/charmsupport/volumes.py
8
9charmsupport/tests/test_execd.py -> charm-helpers/tests/contrib/charmsupport/test_execd.py
10charmsupport/tests/test_hookenv.py -> charm-helpers/tests/contrib/charmsupport/test_hookenv.py
11charmsupport/tests/test_host.py -> charm-helpers/tests/contrib/charmsupport/test_host.py
12charmsupport/tests/test_nrpe.py -> charm-helpers/tests/contrib/charmsupport/test_nrpe.py
13
14charmsupport/bin/charmsupport -> charm-helpers/bin/contrib/charmsupport/charmsupport
015
=== modified file 'hooks/charmhelpers/contrib/charmsupport/nrpe.py'
--- hooks/charmhelpers/contrib/charmsupport/nrpe.py 2016-12-20 14:35:00 +0000
+++ hooks/charmhelpers/contrib/charmsupport/nrpe.py 2021-05-12 04:07:51 +0000
@@ -18,20 +18,22 @@
18# Authors:18# Authors:
19# Matthew Wedgwood <matthew.wedgwood@canonical.com>19# Matthew Wedgwood <matthew.wedgwood@canonical.com>
2020
21import subprocess21import glob
22import pwd
23import grp22import grp
24import os23import os
25import glob24import pwd
26import shutil
27import re25import re
28import shlex26import shlex
27import shutil
28import subprocess
29import yaml29import yaml
3030
31from charmhelpers.core.hookenv import (31from charmhelpers.core.hookenv import (
32 config,32 config,
33 hook_name,
33 local_unit,34 local_unit,
34 log,35 log,
36 relation_get,
35 relation_ids,37 relation_ids,
36 relation_set,38 relation_set,
37 relations_of_type,39 relations_of_type,
@@ -125,7 +127,7 @@
125127
126128
127class Check(object):129class Check(object):
128 shortname_re = '[A-Za-z0-9-_]+$'130 shortname_re = '[A-Za-z0-9-_.@]+$'
129 service_template = ("""131 service_template = ("""
130#---------------------------------------------------132#---------------------------------------------------
131# This file is Juju managed133# This file is Juju managed
@@ -137,10 +139,11 @@
137 """{description}139 """{description}
138 check_command check_nrpe!{command}140 check_command check_nrpe!{command}
139 servicegroups {nagios_servicegroup}141 servicegroups {nagios_servicegroup}
142{service_config_overrides}
140}}143}}
141""")144""")
142145
143 def __init__(self, shortname, description, check_cmd):146 def __init__(self, shortname, description, check_cmd, max_check_attempts=None):
144 super(Check, self).__init__()147 super(Check, self).__init__()
145 # XXX: could be better to calculate this from the service name148 # XXX: could be better to calculate this from the service name
146 if not re.match(self.shortname_re, shortname):149 if not re.match(self.shortname_re, shortname):
@@ -153,6 +156,7 @@
153 # The default is: illegal_object_name_chars=`~!$%^&*"|'<>?,()=156 # The default is: illegal_object_name_chars=`~!$%^&*"|'<>?,()=
154 self.description = description157 self.description = description
155 self.check_cmd = self._locate_cmd(check_cmd)158 self.check_cmd = self._locate_cmd(check_cmd)
159 self.max_check_attempts = max_check_attempts
156160
157 def _get_check_filename(self):161 def _get_check_filename(self):
158 return os.path.join(NRPE.nrpe_confdir, '{}.cfg'.format(self.command))162 return os.path.join(NRPE.nrpe_confdir, '{}.cfg'.format(self.command))
@@ -193,6 +197,13 @@
193 nrpe_check_file = self._get_check_filename()197 nrpe_check_file = self._get_check_filename()
194 with open(nrpe_check_file, 'w') as nrpe_check_config:198 with open(nrpe_check_file, 'w') as nrpe_check_config:
195 nrpe_check_config.write("# check {}\n".format(self.shortname))199 nrpe_check_config.write("# check {}\n".format(self.shortname))
200 if nagios_servicegroups:
201 nrpe_check_config.write(
202 "# The following header was added automatically by juju\n")
203 nrpe_check_config.write(
204 "# Modifying it will affect nagios monitoring and alerting\n")
205 nrpe_check_config.write(
206 "# servicegroups: {}\n".format(nagios_servicegroups))
196 nrpe_check_config.write("command[{}]={}\n".format(207 nrpe_check_config.write("command[{}]={}\n".format(
197 self.command, self.check_cmd))208 self.command, self.check_cmd))
198209
@@ -207,12 +218,19 @@
207 nagios_servicegroups):218 nagios_servicegroups):
208 self._remove_service_files()219 self._remove_service_files()
209220
221 if self.max_check_attempts:
222 service_config_overrides = ' max_check_attempts {}'.format(
223 self.max_check_attempts
224 ) # Note indentation is here rather than in the template to avoid trailing spaces
225 else:
226 service_config_overrides = '' # empty string to avoid printing 'None'
210 templ_vars = {227 templ_vars = {
211 'nagios_hostname': hostname,228 'nagios_hostname': hostname,
212 'nagios_servicegroup': nagios_servicegroups,229 'nagios_servicegroup': nagios_servicegroups,
213 'description': self.description,230 'description': self.description,
214 'shortname': self.shortname,231 'shortname': self.shortname,
215 'command': self.command,232 'command': self.command,
233 'service_config_overrides': service_config_overrides,
216 }234 }
217 nrpe_service_text = Check.service_template.format(**templ_vars)235 nrpe_service_text = Check.service_template.format(**templ_vars)
218 nrpe_service_file = self._get_service_filename(hostname)236 nrpe_service_file = self._get_service_filename(hostname)
@@ -227,6 +245,7 @@
227 nagios_logdir = '/var/log/nagios'245 nagios_logdir = '/var/log/nagios'
228 nagios_exportdir = '/var/lib/nagios/export'246 nagios_exportdir = '/var/lib/nagios/export'
229 nrpe_confdir = '/etc/nagios/nrpe.d'247 nrpe_confdir = '/etc/nagios/nrpe.d'
248 homedir = '/var/lib/nagios' # home dir provided by nagios-nrpe-server
230249
231 def __init__(self, hostname=None, primary=True):250 def __init__(self, hostname=None, primary=True):
232 super(NRPE, self).__init__()251 super(NRPE, self).__init__()
@@ -251,11 +270,28 @@
251 relation = relation_ids('nrpe-external-master')270 relation = relation_ids('nrpe-external-master')
252 if relation:271 if relation:
253 log("Setting charm primary status {}".format(primary))272 log("Setting charm primary status {}".format(primary))
254 for rid in relation_ids('nrpe-external-master'):273 for rid in relation:
255 relation_set(relation_id=rid, relation_settings={'primary': self.primary})274 relation_set(relation_id=rid, relation_settings={'primary': self.primary})
275 self.remove_check_queue = set()
276
277 @classmethod
278 def does_nrpe_conf_dir_exist(cls):
279 """Return True if th nrpe_confdif directory exists."""
280 return os.path.isdir(cls.nrpe_confdir)
256281
257 def add_check(self, *args, **kwargs):282 def add_check(self, *args, **kwargs):
283 shortname = None
284 if kwargs.get('shortname') is None:
285 if len(args) > 0:
286 shortname = args[0]
287 else:
288 shortname = kwargs['shortname']
289
258 self.checks.append(Check(*args, **kwargs))290 self.checks.append(Check(*args, **kwargs))
291 try:
292 self.remove_check_queue.remove(shortname)
293 except KeyError:
294 pass
259295
260 def remove_check(self, *args, **kwargs):296 def remove_check(self, *args, **kwargs):
261 if kwargs.get('shortname') is None:297 if kwargs.get('shortname') is None:
@@ -272,12 +308,13 @@
272308
273 check = Check(*args, **kwargs)309 check = Check(*args, **kwargs)
274 check.remove(self.hostname)310 check.remove(self.hostname)
311 self.remove_check_queue.add(kwargs['shortname'])
275312
276 def write(self):313 def write(self):
277 try:314 try:
278 nagios_uid = pwd.getpwnam('nagios').pw_uid315 nagios_uid = pwd.getpwnam('nagios').pw_uid
279 nagios_gid = grp.getgrnam('nagios').gr_gid316 nagios_gid = grp.getgrnam('nagios').gr_gid
280 except:317 except Exception:
281 log("Nagios user not set up, nrpe checks not updated")318 log("Nagios user not set up, nrpe checks not updated")
282 return319 return
283320
@@ -287,19 +324,50 @@
287324
288 nrpe_monitors = {}325 nrpe_monitors = {}
289 monitors = {"monitors": {"remote": {"nrpe": nrpe_monitors}}}326 monitors = {"monitors": {"remote": {"nrpe": nrpe_monitors}}}
327
328 # check that the charm can write to the conf dir. If not, then nagios
329 # probably isn't installed, and we can defer.
330 if not self.does_nrpe_conf_dir_exist():
331 return
332
290 for nrpecheck in self.checks:333 for nrpecheck in self.checks:
291 nrpecheck.write(self.nagios_context, self.hostname,334 nrpecheck.write(self.nagios_context, self.hostname,
292 self.nagios_servicegroups)335 self.nagios_servicegroups)
293 nrpe_monitors[nrpecheck.shortname] = {336 nrpe_monitors[nrpecheck.shortname] = {
294 "command": nrpecheck.command,337 "command": nrpecheck.command,
295 }338 }
339 # If we were passed max_check_attempts, add that to the relation data
340 if nrpecheck.max_check_attempts is not None:
341 nrpe_monitors[nrpecheck.shortname]['max_check_attempts'] = nrpecheck.max_check_attempts
296342
297 service('restart', 'nagios-nrpe-server')343 # update-status hooks are configured to firing every 5 minutes by
344 # default. When nagios-nrpe-server is restarted, the nagios server
345 # reports checks failing causing unnecessary alerts. Let's not restart
346 # on update-status hooks.
347 if not hook_name() == 'update-status':
348 service('restart', 'nagios-nrpe-server')
298349
299 monitor_ids = relation_ids("local-monitors") + \350 monitor_ids = relation_ids("local-monitors") + \
300 relation_ids("nrpe-external-master")351 relation_ids("nrpe-external-master")
301 for rid in monitor_ids:352 for rid in monitor_ids:
302 relation_set(relation_id=rid, monitors=yaml.dump(monitors))353 reldata = relation_get(unit=local_unit(), rid=rid)
354 if 'monitors' in reldata:
355 # update the existing set of monitors with the new data
356 old_monitors = yaml.safe_load(reldata['monitors'])
357 old_nrpe_monitors = old_monitors['monitors']['remote']['nrpe']
358 # remove keys that are in the remove_check_queue
359 old_nrpe_monitors = {k: v for k, v in old_nrpe_monitors.items()
360 if k not in self.remove_check_queue}
361 # update/add nrpe_monitors
362 old_nrpe_monitors.update(nrpe_monitors)
363 old_monitors['monitors']['remote']['nrpe'] = old_nrpe_monitors
364 # write back to the relation
365 relation_set(relation_id=rid, monitors=yaml.dump(old_monitors))
366 else:
367 # write a brand new set of monitors, as no existing ones.
368 relation_set(relation_id=rid, monitors=yaml.dump(monitors))
369
370 self.remove_check_queue.clear()
303371
304372
305def get_nagios_hostcontext(relation_name='nrpe-external-master'):373def get_nagios_hostcontext(relation_name='nrpe-external-master'):
@@ -338,13 +406,14 @@
338 return unit406 return unit
339407
340408
341def add_init_service_checks(nrpe, services, unit_name):409def add_init_service_checks(nrpe, services, unit_name, immediate_check=True):
342 """410 """
343 Add checks for each service in list411 Add checks for each service in list
344412
345 :param NRPE nrpe: NRPE object to add check to413 :param NRPE nrpe: NRPE object to add check to
346 :param list services: List of services to check414 :param list services: List of services to check
347 :param str unit_name: Unit name to use in check description415 :param str unit_name: Unit name to use in check description
416 :param bool immediate_check: For sysv init, run the service check immediately
348 """417 """
349 for svc in services:418 for svc in services:
350 # Don't add a check for these services from neutron-gateway419 # Don't add a check for these services from neutron-gateway
@@ -354,7 +423,7 @@
354 upstart_init = '/etc/init/%s.conf' % svc423 upstart_init = '/etc/init/%s.conf' % svc
355 sysv_init = '/etc/init.d/%s' % svc424 sysv_init = '/etc/init.d/%s' % svc
356425
357 if host.init_is_systemd():426 if host.init_is_systemd(service_name=svc):
358 nrpe.add_check(427 nrpe.add_check(
359 shortname=svc,428 shortname=svc,
360 description='process check {%s}' % unit_name,429 description='process check {%s}' % unit_name,
@@ -368,33 +437,53 @@
368 )437 )
369 elif os.path.exists(sysv_init):438 elif os.path.exists(sysv_init):
370 cronpath = '/etc/cron.d/nagios-service-check-%s' % svc439 cronpath = '/etc/cron.d/nagios-service-check-%s' % svc
371 cron_file = ('*/5 * * * * root '440 checkpath = '%s/service-check-%s.txt' % (nrpe.homedir, svc)
372 '/usr/local/lib/nagios/plugins/check_exit_status.pl '441 croncmd = (
373 '-s /etc/init.d/%s status > '442 '/usr/local/lib/nagios/plugins/check_exit_status.pl '
374 '/var/lib/nagios/service-check-%s.txt\n' % (svc,443 '-e -s /etc/init.d/%s status' % svc
375 svc)444 )
376 )445 cron_file = '*/5 * * * * root %s > %s\n' % (croncmd, checkpath)
377 f = open(cronpath, 'w')446 f = open(cronpath, 'w')
378 f.write(cron_file)447 f.write(cron_file)
379 f.close()448 f.close()
380 nrpe.add_check(449 nrpe.add_check(
381 shortname=svc,450 shortname=svc,
382 description='process check {%s}' % unit_name,451 description='service check {%s}' % unit_name,
383 check_cmd='check_status_file.py -f '452 check_cmd='check_status_file.py -f %s' % checkpath,
384 '/var/lib/nagios/service-check-%s.txt' % svc,
385 )453 )
386454 # if /var/lib/nagios doesn't exist open(checkpath, 'w') will fail
387455 # (LP: #1670223).
388def copy_nrpe_checks():456 if immediate_check and os.path.isdir(nrpe.homedir):
457 f = open(checkpath, 'w')
458 subprocess.call(
459 croncmd.split(),
460 stdout=f,
461 stderr=subprocess.STDOUT
462 )
463 f.close()
464 os.chmod(checkpath, 0o644)
465
466
467def copy_nrpe_checks(nrpe_files_dir=None):
389 """468 """
390 Copy the nrpe checks into place469 Copy the nrpe checks into place
391470
392 """471 """
393 NAGIOS_PLUGINS = '/usr/local/lib/nagios/plugins'472 NAGIOS_PLUGINS = '/usr/local/lib/nagios/plugins'
394 nrpe_files_dir = os.path.join(os.getenv('CHARM_DIR'), 'hooks',473 if nrpe_files_dir is None:
395 'charmhelpers', 'contrib', 'openstack',474 # determine if "charmhelpers" is in CHARMDIR or CHARMDIR/hooks
396 'files')475 for segment in ['.', 'hooks']:
397476 nrpe_files_dir = os.path.abspath(os.path.join(
477 os.getenv('CHARM_DIR'),
478 segment,
479 'charmhelpers',
480 'contrib',
481 'openstack',
482 'files'))
483 if os.path.isdir(nrpe_files_dir):
484 break
485 else:
486 raise RuntimeError("Couldn't find charmhelpers directory")
398 if not os.path.exists(NAGIOS_PLUGINS):487 if not os.path.exists(NAGIOS_PLUGINS):
399 os.makedirs(NAGIOS_PLUGINS)488 os.makedirs(NAGIOS_PLUGINS)
400 for fname in glob.glob(os.path.join(nrpe_files_dir, "check_*")):489 for fname in glob.glob(os.path.join(nrpe_files_dir, "check_*")):
@@ -418,3 +507,17 @@
418 shortname='haproxy_queue',507 shortname='haproxy_queue',
419 description='Check HAProxy queue depth {%s}' % unit_name,508 description='Check HAProxy queue depth {%s}' % unit_name,
420 check_cmd='check_haproxy_queue_depth.sh')509 check_cmd='check_haproxy_queue_depth.sh')
510
511
512def remove_deprecated_check(nrpe, deprecated_services):
513 """
514 Remove checks fro deprecated services in list
515
516 :param nrpe: NRPE object to remove check from
517 :type nrpe: NRPE
518 :param deprecated_services: List of deprecated services that are removed
519 :type deprecated_services: list
520 """
521 for dep_svc in deprecated_services:
522 log('Deprecated service: {}'.format(dep_svc))
523 nrpe.remove_check(shortname=dep_svc)
421524
=== added file 'hooks/charmhelpers/contrib/charmsupport/volumes.py'
--- hooks/charmhelpers/contrib/charmsupport/volumes.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/charmsupport/volumes.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,173 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15'''
16Functions for managing volumes in juju units. One volume is supported per unit.
17Subordinates may have their own storage, provided it is on its own partition.
18
19Configuration stanzas::
20
21 volume-ephemeral:
22 type: boolean
23 default: true
24 description: >
25 If false, a volume is mounted as sepecified in "volume-map"
26 If true, ephemeral storage will be used, meaning that log data
27 will only exist as long as the machine. YOU HAVE BEEN WARNED.
28 volume-map:
29 type: string
30 default: {}
31 description: >
32 YAML map of units to device names, e.g:
33 "{ rsyslog/0: /dev/vdb, rsyslog/1: /dev/vdb }"
34 Service units will raise a configure-error if volume-ephemeral
35 is 'true' and no volume-map value is set. Use 'juju set' to set a
36 value and 'juju resolved' to complete configuration.
37
38Usage::
39
40 from charmsupport.volumes import configure_volume, VolumeConfigurationError
41 from charmsupport.hookenv import log, ERROR
42 def post_mount_hook():
43 stop_service('myservice')
44 def post_mount_hook():
45 start_service('myservice')
46
47 if __name__ == '__main__':
48 try:
49 configure_volume(before_change=pre_mount_hook,
50 after_change=post_mount_hook)
51 except VolumeConfigurationError:
52 log('Storage could not be configured', ERROR)
53
54'''
55
56# XXX: Known limitations
57# - fstab is neither consulted nor updated
58
59import os
60from charmhelpers.core import hookenv
61from charmhelpers.core import host
62import yaml
63
64
65MOUNT_BASE = '/srv/juju/volumes'
66
67
68class VolumeConfigurationError(Exception):
69 '''Volume configuration data is missing or invalid'''
70 pass
71
72
73def get_config():
74 '''Gather and sanity-check volume configuration data'''
75 volume_config = {}
76 config = hookenv.config()
77
78 errors = False
79
80 if config.get('volume-ephemeral') in (True, 'True', 'true', 'Yes', 'yes'):
81 volume_config['ephemeral'] = True
82 else:
83 volume_config['ephemeral'] = False
84
85 try:
86 volume_map = yaml.safe_load(config.get('volume-map', '{}'))
87 except yaml.YAMLError as e:
88 hookenv.log("Error parsing YAML volume-map: {}".format(e),
89 hookenv.ERROR)
90 errors = True
91 if volume_map is None:
92 # probably an empty string
93 volume_map = {}
94 elif not isinstance(volume_map, dict):
95 hookenv.log("Volume-map should be a dictionary, not {}".format(
96 type(volume_map)))
97 errors = True
98
99 volume_config['device'] = volume_map.get(os.environ['JUJU_UNIT_NAME'])
100 if volume_config['device'] and volume_config['ephemeral']:
101 # asked for ephemeral storage but also defined a volume ID
102 hookenv.log('A volume is defined for this unit, but ephemeral '
103 'storage was requested', hookenv.ERROR)
104 errors = True
105 elif not volume_config['device'] and not volume_config['ephemeral']:
106 # asked for permanent storage but did not define volume ID
107 hookenv.log('Ephemeral storage was requested, but there is no volume '
108 'defined for this unit.', hookenv.ERROR)
109 errors = True
110
111 unit_mount_name = hookenv.local_unit().replace('/', '-')
112 volume_config['mountpoint'] = os.path.join(MOUNT_BASE, unit_mount_name)
113
114 if errors:
115 return None
116 return volume_config
117
118
119def mount_volume(config):
120 if os.path.exists(config['mountpoint']):
121 if not os.path.isdir(config['mountpoint']):
122 hookenv.log('Not a directory: {}'.format(config['mountpoint']))
123 raise VolumeConfigurationError()
124 else:
125 host.mkdir(config['mountpoint'])
126 if os.path.ismount(config['mountpoint']):
127 unmount_volume(config)
128 if not host.mount(config['device'], config['mountpoint'], persist=True):
129 raise VolumeConfigurationError()
130
131
132def unmount_volume(config):
133 if os.path.ismount(config['mountpoint']):
134 if not host.umount(config['mountpoint'], persist=True):
135 raise VolumeConfigurationError()
136
137
138def managed_mounts():
139 '''List of all mounted managed volumes'''
140 return filter(lambda mount: mount[0].startswith(MOUNT_BASE), host.mounts())
141
142
143def configure_volume(before_change=lambda: None, after_change=lambda: None):
144 '''Set up storage (or don't) according to the charm's volume configuration.
145 Returns the mount point or "ephemeral". before_change and after_change
146 are optional functions to be called if the volume configuration changes.
147 '''
148
149 config = get_config()
150 if not config:
151 hookenv.log('Failed to read volume configuration', hookenv.CRITICAL)
152 raise VolumeConfigurationError()
153
154 if config['ephemeral']:
155 if os.path.ismount(config['mountpoint']):
156 before_change()
157 unmount_volume(config)
158 after_change()
159 return 'ephemeral'
160 else:
161 # persistent storage
162 if os.path.ismount(config['mountpoint']):
163 mounts = dict(managed_mounts())
164 if mounts.get(config['mountpoint']) != config['device']:
165 before_change()
166 unmount_volume(config)
167 mount_volume(config)
168 after_change()
169 else:
170 before_change()
171 mount_volume(config)
172 after_change()
173 return config['mountpoint']
0174
=== added directory 'hooks/charmhelpers/contrib/database'
=== added file 'hooks/charmhelpers/contrib/database/__init__.py'
--- hooks/charmhelpers/contrib/database/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/database/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,11 @@
1# Licensed under the Apache License, Version 2.0 (the "License");
2# you may not use this file except in compliance with the License.
3# You may obtain a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS,
9# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10# See the License for the specific language governing permissions and
11# limitations under the License.
012
=== added file 'hooks/charmhelpers/contrib/database/mysql.py'
--- hooks/charmhelpers/contrib/database/mysql.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/database/mysql.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,840 @@
1# Licensed under the Apache License, Version 2.0 (the "License");
2# you may not use this file except in compliance with the License.
3# You may obtain a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS,
9# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10# See the License for the specific language governing permissions and
11# limitations under the License.
12
13"""Helper for working with a MySQL database"""
14import collections
15import copy
16import json
17import re
18import sys
19import platform
20import os
21import glob
22import six
23
24# from string import upper
25
26from charmhelpers.core.host import (
27 CompareHostReleases,
28 lsb_release,
29 mkdir,
30 pwgen,
31 write_file
32)
33from charmhelpers.core.hookenv import (
34 config as config_get,
35 relation_get,
36 related_units,
37 unit_get,
38 log,
39 DEBUG,
40 ERROR,
41 INFO,
42 WARNING,
43 leader_get,
44 leader_set,
45 is_leader,
46)
47from charmhelpers.fetch import (
48 apt_install,
49 apt_update,
50 filter_installed_packages,
51)
52from charmhelpers.contrib.network.ip import get_host_ip
53
54try:
55 import MySQLdb
56except ImportError:
57 apt_update(fatal=True)
58 if six.PY2:
59 apt_install(filter_installed_packages(['python-mysqldb']), fatal=True)
60 else:
61 apt_install(filter_installed_packages(['python3-mysqldb']), fatal=True)
62 import MySQLdb
63
64
65class MySQLSetPasswordError(Exception):
66 pass
67
68
69class MySQLHelper(object):
70
71 def __init__(self, rpasswdf_template, upasswdf_template, host='localhost',
72 migrate_passwd_to_leader_storage=True,
73 delete_ondisk_passwd_file=True, user="root", password=None,
74 port=None, connect_timeout=None):
75 self.user = user
76 self.host = host
77 self.password = password
78 self.port = port
79 # default timeout of 30 seconds.
80 self.connect_timeout = connect_timeout or 30
81
82 # Password file path templates
83 self.root_passwd_file_template = rpasswdf_template
84 self.user_passwd_file_template = upasswdf_template
85
86 self.migrate_passwd_to_leader_storage = migrate_passwd_to_leader_storage
87 # If we migrate we have the option to delete local copy of root passwd
88 self.delete_ondisk_passwd_file = delete_ondisk_passwd_file
89 self.connection = None
90
91 def connect(self, user='root', password=None, host=None, port=None,
92 connect_timeout=None):
93 _connection_info = {
94 "user": user or self.user,
95 "passwd": password or self.password,
96 "host": host or self.host
97 }
98 # set the connection timeout; for mysql8 it can hang forever, so some
99 # timeout is required.
100 timeout = connect_timeout or self.connect_timeout
101 if timeout:
102 _connection_info["connect_timeout"] = timeout
103 # port cannot be None but we also do not want to specify it unless it
104 # has been explicit set.
105 port = port or self.port
106 if port is not None:
107 _connection_info["port"] = port
108
109 log("Opening db connection for %s@%s" % (user, host), level=DEBUG)
110 try:
111 self.connection = MySQLdb.connect(**_connection_info)
112 except Exception as e:
113 log("Failed to connect to database due to '{}'".format(str(e)),
114 level=ERROR)
115 raise
116
117 def database_exists(self, db_name):
118 cursor = self.connection.cursor()
119 try:
120 cursor.execute("SHOW DATABASES")
121 databases = [i[0] for i in cursor.fetchall()]
122 finally:
123 cursor.close()
124
125 return db_name in databases
126
127 def create_database(self, db_name):
128 cursor = self.connection.cursor()
129 try:
130 cursor.execute("CREATE DATABASE `{}` CHARACTER SET UTF8"
131 .format(db_name))
132 finally:
133 cursor.close()
134
135 def grant_exists(self, db_name, db_user, remote_ip):
136 cursor = self.connection.cursor()
137 priv_string = "GRANT ALL PRIVILEGES ON `{}`.* " \
138 "TO '{}'@'{}'".format(db_name, db_user, remote_ip)
139 try:
140 cursor.execute("SHOW GRANTS for '{}'@'{}'".format(db_user,
141 remote_ip))
142 grants = [i[0] for i in cursor.fetchall()]
143 except MySQLdb.OperationalError:
144 return False
145 finally:
146 cursor.close()
147
148 # TODO: review for different grants
149 return priv_string in grants
150
151 def create_grant(self, db_name, db_user, remote_ip, password):
152 cursor = self.connection.cursor()
153 try:
154 # TODO: review for different grants
155 cursor.execute("GRANT ALL PRIVILEGES ON `{}`.* TO '{}'@'{}' "
156 "IDENTIFIED BY '{}'".format(db_name,
157 db_user,
158 remote_ip,
159 password))
160 finally:
161 cursor.close()
162
163 def create_admin_grant(self, db_user, remote_ip, password):
164 cursor = self.connection.cursor()
165 try:
166 cursor.execute("GRANT ALL PRIVILEGES ON *.* TO '{}'@'{}' "
167 "IDENTIFIED BY '{}'".format(db_user,
168 remote_ip,
169 password))
170 finally:
171 cursor.close()
172
173 def cleanup_grant(self, db_user, remote_ip):
174 cursor = self.connection.cursor()
175 try:
176 cursor.execute("DROP FROM mysql.user WHERE user='{}' "
177 "AND HOST='{}'".format(db_user,
178 remote_ip))
179 finally:
180 cursor.close()
181
182 def flush_priviledges(self):
183 cursor = self.connection.cursor()
184 try:
185 cursor.execute("FLUSH PRIVILEGES")
186 finally:
187 cursor.close()
188
189 def execute(self, sql):
190 """Execute arbitary SQL against the database."""
191 cursor = self.connection.cursor()
192 try:
193 cursor.execute(sql)
194 finally:
195 cursor.close()
196
197 def select(self, sql):
198 """
199 Execute arbitrary SQL select query against the database
200 and return the results.
201
202 :param sql: SQL select query to execute
203 :type sql: string
204 :returns: SQL select query result
205 :rtype: list of lists
206 :raises: MySQLdb.Error
207 """
208 cursor = self.connection.cursor()
209 try:
210 cursor.execute(sql)
211 results = [list(i) for i in cursor.fetchall()]
212 finally:
213 cursor.close()
214 return results
215
216 def migrate_passwords_to_leader_storage(self, excludes=None):
217 """Migrate any passwords storage on disk to leader storage."""
218 if not is_leader():
219 log("Skipping password migration as not the lead unit",
220 level=DEBUG)
221 return
222 dirname = os.path.dirname(self.root_passwd_file_template)
223 path = os.path.join(dirname, '*.passwd')
224 for f in glob.glob(path):
225 if excludes and f in excludes:
226 log("Excluding %s from leader storage migration" % (f),
227 level=DEBUG)
228 continue
229
230 key = os.path.basename(f)
231 with open(f, 'r') as passwd:
232 _value = passwd.read().strip()
233
234 try:
235 leader_set(settings={key: _value})
236
237 if self.delete_ondisk_passwd_file:
238 os.unlink(f)
239 except ValueError:
240 # NOTE cluster relation not yet ready - skip for now
241 pass
242
243 def get_mysql_password_on_disk(self, username=None, password=None):
244 """Retrieve, generate or store a mysql password for the provided
245 username on disk."""
246 if username:
247 template = self.user_passwd_file_template
248 passwd_file = template.format(username)
249 else:
250 passwd_file = self.root_passwd_file_template
251
252 _password = None
253 if os.path.exists(passwd_file):
254 log("Using existing password file '%s'" % passwd_file, level=DEBUG)
255 with open(passwd_file, 'r') as passwd:
256 _password = passwd.read().strip()
257 else:
258 log("Generating new password file '%s'" % passwd_file, level=DEBUG)
259 if not os.path.isdir(os.path.dirname(passwd_file)):
260 # NOTE: need to ensure this is not mysql root dir (which needs
261 # to be mysql readable)
262 mkdir(os.path.dirname(passwd_file), owner='root', group='root',
263 perms=0o770)
264 # Force permissions - for some reason the chmod in makedirs
265 # fails
266 os.chmod(os.path.dirname(passwd_file), 0o770)
267
268 _password = password or pwgen(length=32)
269 write_file(passwd_file, _password, owner='root', group='root',
270 perms=0o660)
271
272 return _password
273
274 def passwd_keys(self, username):
275 """Generator to return keys used to store passwords in peer store.
276
277 NOTE: we support both legacy and new format to support mysql
278 charm prior to refactor. This is necessary to avoid LP 1451890.
279 """
280 keys = []
281 if username == 'mysql':
282 log("Bad username '%s'" % (username), level=WARNING)
283
284 if username:
285 # IMPORTANT: *newer* format must be returned first
286 keys.append('mysql-%s.passwd' % (username))
287 keys.append('%s.passwd' % (username))
288 else:
289 keys.append('mysql.passwd')
290
291 for key in keys:
292 yield key
293
294 def get_mysql_password(self, username=None, password=None):
295 """Retrieve, generate or store a mysql password for the provided
296 username using peer relation cluster."""
297 excludes = []
298
299 # First check peer relation.
300 try:
301 for key in self.passwd_keys(username):
302 _password = leader_get(key)
303 if _password:
304 break
305
306 # If root password available don't update peer relation from local
307 if _password and not username:
308 excludes.append(self.root_passwd_file_template)
309
310 except ValueError:
311 # cluster relation is not yet started; use on-disk
312 _password = None
313
314 # If none available, generate new one
315 if not _password:
316 _password = self.get_mysql_password_on_disk(username, password)
317
318 # Put on wire if required
319 if self.migrate_passwd_to_leader_storage:
320 self.migrate_passwords_to_leader_storage(excludes=excludes)
321
322 return _password
323
324 def get_mysql_root_password(self, password=None):
325 """Retrieve or generate mysql root password for service units."""
326 return self.get_mysql_password(username=None, password=password)
327
328 def set_mysql_password(self, username, password, current_password=None):
329 """Update a mysql password for the provided username changing the
330 leader settings
331
332 To update root's password pass `None` in the username
333
334 :param username: Username to change password of
335 :type username: str
336 :param password: New password for user.
337 :type password: str
338 :param current_password: Existing password for user.
339 :type current_password: str
340 """
341
342 if username is None:
343 username = 'root'
344
345 # get root password via leader-get, it may be that in the past (when
346 # changes to root-password were not supported) the user changed the
347 # password, so leader-get is more reliable source than
348 # config.previous('root-password').
349 rel_username = None if username == 'root' else username
350 if not current_password:
351 current_password = self.get_mysql_password(rel_username)
352
353 # password that needs to be set
354 new_passwd = password
355
356 # update password for all users (e.g. root@localhost, root@::1, etc)
357 try:
358 self.connect(user=username, password=current_password)
359 cursor = self.connection.cursor()
360 except MySQLdb.OperationalError as ex:
361 raise MySQLSetPasswordError(('Cannot connect using password in '
362 'leader settings (%s)') % ex, ex)
363
364 try:
365 # NOTE(freyes): Due to skip-name-resolve root@$HOSTNAME account
366 # fails when using SET PASSWORD so using UPDATE against the
367 # mysql.user table is needed, but changes to this table are not
368 # replicated across the cluster, so this update needs to run in
369 # all the nodes. More info at
370 # http://galeracluster.com/documentation-webpages/userchanges.html
371 release = CompareHostReleases(lsb_release()['DISTRIB_CODENAME'])
372 if release < 'bionic':
373 SQL_UPDATE_PASSWD = ("UPDATE mysql.user SET password = "
374 "PASSWORD( %s ) WHERE user = %s;")
375 else:
376 # PXC 5.7 (introduced in Bionic) uses authentication_string
377 SQL_UPDATE_PASSWD = ("UPDATE mysql.user SET "
378 "authentication_string = "
379 "PASSWORD( %s ) WHERE user = %s;")
380 cursor.execute(SQL_UPDATE_PASSWD, (new_passwd, username))
381 cursor.execute('FLUSH PRIVILEGES;')
382 self.connection.commit()
383 except MySQLdb.OperationalError as ex:
384 raise MySQLSetPasswordError('Cannot update password: %s' % str(ex),
385 ex)
386 finally:
387 cursor.close()
388
389 # check the password was changed
390 try:
391 self.connect(user=username, password=new_passwd)
392 self.execute('select 1;')
393 except MySQLdb.OperationalError as ex:
394 raise MySQLSetPasswordError(('Cannot connect using new password: '
395 '%s') % str(ex), ex)
396
397 if not is_leader():
398 log('Only the leader can set a new password in the relation',
399 level=DEBUG)
400 return
401
402 for key in self.passwd_keys(rel_username):
403 _password = leader_get(key)
404 if _password:
405 log('Updating password for %s (%s)' % (key, rel_username),
406 level=DEBUG)
407 leader_set(settings={key: new_passwd})
408
409 def set_mysql_root_password(self, password, current_password=None):
410 """Update mysql root password changing the leader settings
411
412 :param password: New password for user.
413 :type password: str
414 :param current_password: Existing password for user.
415 :type current_password: str
416 """
417 self.set_mysql_password(
418 'root',
419 password,
420 current_password=current_password)
421
422 def normalize_address(self, hostname):
423 """Ensure that address returned is an IP address (i.e. not fqdn)"""
424 if config_get('prefer-ipv6'):
425 # TODO: add support for ipv6 dns
426 return hostname
427
428 if hostname != unit_get('private-address'):
429 return get_host_ip(hostname, fallback=hostname)
430
431 # Otherwise assume localhost
432 return '127.0.0.1'
433
434 def get_allowed_units(self, database, username, relation_id=None, prefix=None):
435 """Get list of units with access grants for database with username.
436
437 This is typically used to provide shared-db relations with a list of
438 which units have been granted access to the given database.
439 """
440 if not self.connection:
441 self.connect(password=self.get_mysql_root_password())
442 allowed_units = set()
443 if not prefix:
444 prefix = database
445 for unit in related_units(relation_id):
446 settings = relation_get(rid=relation_id, unit=unit)
447 # First check for setting with prefix, then without
448 for attr in ["%s_hostname" % (prefix), 'hostname']:
449 hosts = settings.get(attr, None)
450 if hosts:
451 break
452
453 if hosts:
454 # hostname can be json-encoded list of hostnames
455 try:
456 hosts = json.loads(hosts)
457 except ValueError:
458 hosts = [hosts]
459 else:
460 hosts = [settings['private-address']]
461
462 if hosts:
463 for host in hosts:
464 host = self.normalize_address(host)
465 if self.grant_exists(database, username, host):
466 log("Grant exists for host '%s' on db '%s'" %
467 (host, database), level=DEBUG)
468 if unit not in allowed_units:
469 allowed_units.add(unit)
470 else:
471 log("Grant does NOT exist for host '%s' on db '%s'" %
472 (host, database), level=DEBUG)
473 else:
474 log("No hosts found for grant check", level=INFO)
475
476 return allowed_units
477
478 def configure_db(self, hostname, database, username, admin=False):
479 """Configure access to database for username from hostname."""
480 if not self.connection:
481 self.connect(password=self.get_mysql_root_password())
482 if not self.database_exists(database):
483 self.create_database(database)
484
485 remote_ip = self.normalize_address(hostname)
486 password = self.get_mysql_password(username)
487 if not self.grant_exists(database, username, remote_ip):
488 if not admin:
489 self.create_grant(database, username, remote_ip, password)
490 else:
491 self.create_admin_grant(username, remote_ip, password)
492 self.flush_priviledges()
493
494 return password
495
496
497# `_singleton_config_helper` stores the instance of the helper class that is
498# being used during a hook invocation.
499_singleton_config_helper = None
500
501
502def get_mysql_config_helper():
503 global _singleton_config_helper
504 if _singleton_config_helper is None:
505 _singleton_config_helper = MySQLConfigHelper()
506 return _singleton_config_helper
507
508
509class MySQLConfigHelper(object):
510 """Base configuration helper for MySQL."""
511
512 # Going for the biggest page size to avoid wasted bytes.
513 # InnoDB page size is 16MB
514
515 DEFAULT_PAGE_SIZE = 16 * 1024 * 1024
516 DEFAULT_INNODB_BUFFER_FACTOR = 0.50
517 DEFAULT_INNODB_BUFFER_SIZE_MAX = 512 * 1024 * 1024
518
519 # Validation and lookups for InnoDB configuration
520 INNODB_VALID_BUFFERING_VALUES = [
521 'none',
522 'inserts',
523 'deletes',
524 'changes',
525 'purges',
526 'all'
527 ]
528 INNODB_FLUSH_CONFIG_VALUES = {
529 'fast': 2,
530 'safest': 1,
531 'unsafe': 0,
532 }
533
534 def human_to_bytes(self, human):
535 """Convert human readable configuration options to bytes."""
536 num_re = re.compile('^[0-9]+$')
537 if num_re.match(human):
538 return human
539
540 factors = {
541 'K': 1024,
542 'M': 1048576,
543 'G': 1073741824,
544 'T': 1099511627776
545 }
546 modifier = human[-1]
547 if modifier in factors:
548 return int(human[:-1]) * factors[modifier]
549
550 if modifier == '%':
551 total_ram = self.human_to_bytes(self.get_mem_total())
552 if self.is_32bit_system() and total_ram > self.sys_mem_limit():
553 total_ram = self.sys_mem_limit()
554 factor = int(human[:-1]) * 0.01
555 pctram = total_ram * factor
556 return int(pctram - (pctram % self.DEFAULT_PAGE_SIZE))
557
558 raise ValueError("Can only convert K,M,G, or T")
559
560 def is_32bit_system(self):
561 """Determine whether system is 32 or 64 bit."""
562 try:
563 return sys.maxsize < 2 ** 32
564 except OverflowError:
565 return False
566
567 def sys_mem_limit(self):
568 """Determine the default memory limit for the current service unit."""
569 if platform.machine() in ['armv7l']:
570 _mem_limit = self.human_to_bytes('2700M') # experimentally determined
571 else:
572 # Limit for x86 based 32bit systems
573 _mem_limit = self.human_to_bytes('4G')
574
575 return _mem_limit
576
577 def get_mem_total(self):
578 """Calculate the total memory in the current service unit."""
579 with open('/proc/meminfo') as meminfo_file:
580 for line in meminfo_file:
581 key, mem = line.split(':', 2)
582 if key == 'MemTotal':
583 mtot, modifier = mem.strip().split(' ')
584 return '%s%s' % (mtot, modifier[0].upper())
585
586 def get_innodb_flush_log_at_trx_commit(self):
587 """Get value for innodb_flush_log_at_trx_commit.
588
589 Use the innodb-flush-log-at-trx-commit or the tunning-level setting
590 translated by INNODB_FLUSH_CONFIG_VALUES to get the
591 innodb_flush_log_at_trx_commit value.
592
593 :returns: Numeric value for innodb_flush_log_at_trx_commit
594 :rtype: Union[None, int]
595 """
596 _iflatc = config_get('innodb-flush-log-at-trx-commit')
597 _tuning_level = config_get('tuning-level')
598 if _iflatc:
599 return _iflatc
600 elif _tuning_level:
601 return self.INNODB_FLUSH_CONFIG_VALUES.get(_tuning_level, 1)
602
603 def get_innodb_change_buffering(self):
604 """Get value for innodb_change_buffering.
605
606 Use the innodb-change-buffering validated against
607 INNODB_VALID_BUFFERING_VALUES to get the innodb_change_buffering value.
608
609 :returns: String value for innodb_change_buffering.
610 :rtype: Union[None, str]
611 """
612 _icb = config_get('innodb-change-buffering')
613 if _icb and _icb in self.INNODB_VALID_BUFFERING_VALUES:
614 return _icb
615
616 def get_innodb_buffer_pool_size(self):
617 """Get value for innodb_buffer_pool_size.
618
619 Return the number value of innodb-buffer-pool-size or dataset-size. If
620 neither is set, calculate a sane default based on total memory.
621
622 :returns: Numeric value for innodb_buffer_pool_size.
623 :rtype: int
624 """
625 total_memory = self.human_to_bytes(self.get_mem_total())
626
627 dataset_bytes = config_get('dataset-size')
628 innodb_buffer_pool_size = config_get('innodb-buffer-pool-size')
629
630 if innodb_buffer_pool_size:
631 innodb_buffer_pool_size = self.human_to_bytes(
632 innodb_buffer_pool_size)
633 elif dataset_bytes:
634 log("Option 'dataset-size' has been deprecated, please use"
635 "innodb_buffer_pool_size option instead", level="WARN")
636 innodb_buffer_pool_size = self.human_to_bytes(
637 dataset_bytes)
638 else:
639 # NOTE(jamespage): pick the smallest of 50% of RAM or 512MB
640 # to ensure that deployments in containers
641 # without constraints don't try to consume
642 # silly amounts of memory.
643 innodb_buffer_pool_size = min(
644 int(total_memory * self.DEFAULT_INNODB_BUFFER_FACTOR),
645 self.DEFAULT_INNODB_BUFFER_SIZE_MAX
646 )
647
648 if innodb_buffer_pool_size > total_memory:
649 log("innodb_buffer_pool_size; {} is greater than system available memory:{}".format(
650 innodb_buffer_pool_size,
651 total_memory), level='WARN')
652
653 return innodb_buffer_pool_size
654
655
656class PerconaClusterHelper(MySQLConfigHelper):
657 """Percona-cluster specific configuration helper."""
658
659 def parse_config(self):
660 """Parse charm configuration and calculate values for config files."""
661 config = config_get()
662 mysql_config = {}
663 if 'max-connections' in config:
664 mysql_config['max_connections'] = config['max-connections']
665
666 if 'wait-timeout' in config:
667 mysql_config['wait_timeout'] = config['wait-timeout']
668
669 if self.get_innodb_flush_log_at_trx_commit() is not None:
670 mysql_config['innodb_flush_log_at_trx_commit'] = \
671 self.get_innodb_flush_log_at_trx_commit()
672
673 if self.get_innodb_change_buffering() is not None:
674 mysql_config['innodb_change_buffering'] = config['innodb-change-buffering']
675
676 if 'innodb-io-capacity' in config:
677 mysql_config['innodb_io_capacity'] = config['innodb-io-capacity']
678
679 # Set a sane default key_buffer size
680 mysql_config['key_buffer'] = self.human_to_bytes('32M')
681 mysql_config['innodb_buffer_pool_size'] = self.get_innodb_buffer_pool_size()
682 return mysql_config
683
684
685class MySQL8Helper(MySQLHelper):
686
687 def grant_exists(self, db_name, db_user, remote_ip):
688 cursor = self.connection.cursor()
689 priv_string = ("GRANT ALL PRIVILEGES ON {}.* "
690 "TO {}@{}".format(db_name, db_user, remote_ip))
691 try:
692 cursor.execute("SHOW GRANTS FOR '{}'@'{}'".format(db_user,
693 remote_ip))
694 grants = [i[0] for i in cursor.fetchall()]
695 except MySQLdb.OperationalError:
696 return False
697 finally:
698 cursor.close()
699
700 # Different versions of MySQL use ' or `. Ignore these in the check.
701 return priv_string in [
702 i.replace("'", "").replace("`", "") for i in grants]
703
704 def create_grant(self, db_name, db_user, remote_ip, password):
705 if self.grant_exists(db_name, db_user, remote_ip):
706 return
707
708 # Make sure the user exists
709 # MySQL8 must create the user before the grant
710 self.create_user(db_user, remote_ip, password)
711
712 cursor = self.connection.cursor()
713 try:
714 cursor.execute("GRANT ALL PRIVILEGES ON `{}`.* TO '{}'@'{}'"
715 .format(db_name, db_user, remote_ip))
716 finally:
717 cursor.close()
718
719 def create_user(self, db_user, remote_ip, password):
720
721 SQL_USER_CREATE = (
722 "CREATE USER '{db_user}'@'{remote_ip}' "
723 "IDENTIFIED BY '{password}'")
724
725 cursor = self.connection.cursor()
726 try:
727 cursor.execute(SQL_USER_CREATE.format(
728 db_user=db_user,
729 remote_ip=remote_ip,
730 password=password)
731 )
732 except MySQLdb._exceptions.OperationalError:
733 log("DB user {} already exists.".format(db_user),
734 "WARNING")
735 finally:
736 cursor.close()
737
738 def create_router_grant(self, db_user, remote_ip, password):
739
740 # Make sure the user exists
741 # MySQL8 must create the user before the grant
742 self.create_user(db_user, remote_ip, password)
743
744 # Mysql-Router specific grants
745 cursor = self.connection.cursor()
746 try:
747 cursor.execute("GRANT CREATE USER ON *.* TO '{}'@'{}' WITH GRANT "
748 "OPTION".format(db_user, remote_ip))
749 cursor.execute("GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON "
750 "mysql_innodb_cluster_metadata.* TO '{}'@'{}'"
751 .format(db_user, remote_ip))
752 cursor.execute("GRANT SELECT ON mysql.user TO '{}'@'{}'"
753 .format(db_user, remote_ip))
754 cursor.execute("GRANT SELECT ON "
755 "performance_schema.replication_group_members "
756 "TO '{}'@'{}'".format(db_user, remote_ip))
757 cursor.execute("GRANT SELECT ON "
758 "performance_schema.replication_group_member_stats "
759 "TO '{}'@'{}'".format(db_user, remote_ip))
760 cursor.execute("GRANT SELECT ON "
761 "performance_schema.global_variables "
762 "TO '{}'@'{}'".format(db_user, remote_ip))
763 finally:
764 cursor.close()
765
766 def configure_router(self, hostname, username):
767
768 if self.connection is None:
769 self.connect(password=self.get_mysql_root_password())
770
771 remote_ip = self.normalize_address(hostname)
772 password = self.get_mysql_password(username)
773 self.create_user(username, remote_ip, password)
774 self.create_router_grant(username, remote_ip, password)
775
776 return password
777
778
779def get_prefix(requested, keys=None):
780 """Return existing prefix or None.
781
782 :param requested: Request string. i.e. novacell0_username
783 :type requested: str
784 :param keys: Keys to determine prefix. Defaults set in function.
785 :type keys: List of str keys
786 :returns: String prefix i.e. novacell0
787 :rtype: Union[None, str]
788 """
789 if keys is None:
790 # Shared-DB default keys
791 keys = ["_database", "_username", "_hostname"]
792 for key in keys:
793 if requested.endswith(key):
794 return requested[:-len(key)]
795
796
797def get_db_data(relation_data, unprefixed):
798 """Organize database requests into a collections.OrderedDict
799
800 :param relation_data: shared-db relation data
801 :type relation_data: dict
802 :param unprefixed: Prefix to use for requests without a prefix. This should
803 be unique for each side of the relation to avoid
804 conflicts.
805 :type unprefixed: str
806 :returns: Order dict of databases and users
807 :rtype: collections.OrderedDict
808 """
809 # Deep copy to avoid unintentionally changing relation data
810 settings = copy.deepcopy(relation_data)
811 databases = collections.OrderedDict()
812
813 # Clear non-db related elements
814 if "egress-subnets" in settings.keys():
815 settings.pop("egress-subnets")
816 if "ingress-address" in settings.keys():
817 settings.pop("ingress-address")
818 if "private-address" in settings.keys():
819 settings.pop("private-address")
820
821 singleset = {"database", "username", "hostname"}
822 if singleset.issubset(settings):
823 settings["{}_{}".format(unprefixed, "hostname")] = (
824 settings["hostname"])
825 settings.pop("hostname")
826 settings["{}_{}".format(unprefixed, "database")] = (
827 settings["database"])
828 settings.pop("database")
829 settings["{}_{}".format(unprefixed, "username")] = (
830 settings["username"])
831 settings.pop("username")
832
833 for k, v in settings.items():
834 db = k.split("_")[0]
835 x = "_".join(k.split("_")[1:])
836 if db not in databases:
837 databases[db] = collections.OrderedDict()
838 databases[db][x] = v
839
840 return databases
0841
=== added directory 'hooks/charmhelpers/contrib/hahelpers'
=== added file 'hooks/charmhelpers/contrib/hahelpers/__init__.py'
--- hooks/charmhelpers/contrib/hahelpers/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hahelpers/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,13 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
014
=== added file 'hooks/charmhelpers/contrib/hahelpers/apache.py'
--- hooks/charmhelpers/contrib/hahelpers/apache.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hahelpers/apache.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,90 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15#
16# Copyright 2012 Canonical Ltd.
17#
18# This file is sourced from lp:openstack-charm-helpers
19#
20# Authors:
21# James Page <james.page@ubuntu.com>
22# Adam Gandelman <adamg@ubuntu.com>
23#
24
25import os
26
27from charmhelpers.core import host
28from charmhelpers.core.hookenv import (
29 config as config_get,
30 relation_get,
31 relation_ids,
32 related_units as relation_list,
33 log,
34 INFO,
35)
36
37# This file contains the CA cert from the charms ssl_ca configuration
38# option, in future the file name should be updated reflect that.
39CONFIG_CA_CERT_FILE = 'keystone_juju_ca_cert'
40
41
42def get_cert(cn=None):
43 # TODO: deal with multiple https endpoints via charm config
44 cert = config_get('ssl_cert')
45 key = config_get('ssl_key')
46 if not (cert and key):
47 log("Inspecting identity-service relations for SSL certificate.",
48 level=INFO)
49 cert = key = None
50 if cn:
51 ssl_cert_attr = 'ssl_cert_{}'.format(cn)
52 ssl_key_attr = 'ssl_key_{}'.format(cn)
53 else:
54 ssl_cert_attr = 'ssl_cert'
55 ssl_key_attr = 'ssl_key'
56 for r_id in relation_ids('identity-service'):
57 for unit in relation_list(r_id):
58 if not cert:
59 cert = relation_get(ssl_cert_attr,
60 rid=r_id, unit=unit)
61 if not key:
62 key = relation_get(ssl_key_attr,
63 rid=r_id, unit=unit)
64 return (cert, key)
65
66
67def get_ca_cert():
68 ca_cert = config_get('ssl_ca')
69 if ca_cert is None:
70 log("Inspecting identity-service relations for CA SSL certificate.",
71 level=INFO)
72 for r_id in (relation_ids('identity-service') +
73 relation_ids('identity-credentials')):
74 for unit in relation_list(r_id):
75 if ca_cert is None:
76 ca_cert = relation_get('ca_cert',
77 rid=r_id, unit=unit)
78 return ca_cert
79
80
81def retrieve_ca_cert(cert_file):
82 cert = None
83 if os.path.isfile(cert_file):
84 with open(cert_file, 'rb') as crt:
85 cert = crt.read()
86 return cert
87
88
89def install_ca_cert(ca_cert):
90 host.install_ca_cert(ca_cert, CONFIG_CA_CERT_FILE)
091
=== added file 'hooks/charmhelpers/contrib/hahelpers/cluster.py'
--- hooks/charmhelpers/contrib/hahelpers/cluster.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hahelpers/cluster.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,451 @@
1# Copyright 2014-2015 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15#
16# Copyright 2012 Canonical Ltd.
17#
18# Authors:
19# James Page <james.page@ubuntu.com>
20# Adam Gandelman <adamg@ubuntu.com>
21#
22
23"""
24Helpers for clustering and determining "cluster leadership" and other
25clustering-related helpers.
26"""
27
28import functools
29import subprocess
30import os
31import time
32
33from socket import gethostname as get_unit_hostname
34
35import six
36
37from charmhelpers.core.hookenv import (
38 log,
39 relation_ids,
40 related_units as relation_list,
41 relation_get,
42 config as config_get,
43 INFO,
44 DEBUG,
45 WARNING,
46 unit_get,
47 is_leader as juju_is_leader,
48 status_set,
49)
50from charmhelpers.core.host import (
51 modulo_distribution,
52)
53from charmhelpers.core.decorators import (
54 retry_on_exception,
55)
56from charmhelpers.core.strutils import (
57 bool_from_string,
58)
59
60DC_RESOURCE_NAME = 'DC'
61
62
63class HAIncompleteConfig(Exception):
64 pass
65
66
67class HAIncorrectConfig(Exception):
68 pass
69
70
71class CRMResourceNotFound(Exception):
72 pass
73
74
75class CRMDCNotFound(Exception):
76 pass
77
78
79def is_elected_leader(resource):
80 """
81 Returns True if the charm executing this is the elected cluster leader.
82
83 It relies on two mechanisms to determine leadership:
84 1. If juju is sufficiently new and leadership election is supported,
85 the is_leader command will be used.
86 2. If the charm is part of a corosync cluster, call corosync to
87 determine leadership.
88 3. If the charm is not part of a corosync cluster, the leader is
89 determined as being "the alive unit with the lowest unit numer". In
90 other words, the oldest surviving unit.
91 """
92 try:
93 return juju_is_leader()
94 except NotImplementedError:
95 log('Juju leadership election feature not enabled'
96 ', using fallback support',
97 level=WARNING)
98
99 if is_clustered():
100 if not is_crm_leader(resource):
101 log('Deferring action to CRM leader.', level=INFO)
102 return False
103 else:
104 peers = peer_units()
105 if peers and not oldest_peer(peers):
106 log('Deferring action to oldest service unit.', level=INFO)
107 return False
108 return True
109
110
111def is_clustered():
112 for r_id in (relation_ids('ha') or []):
113 for unit in (relation_list(r_id) or []):
114 clustered = relation_get('clustered',
115 rid=r_id,
116 unit=unit)
117 if clustered:
118 return True
119 return False
120
121
122def is_crm_dc():
123 """
124 Determine leadership by querying the pacemaker Designated Controller
125 """
126 cmd = ['crm', 'status']
127 try:
128 status = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
129 if not isinstance(status, six.text_type):
130 status = six.text_type(status, "utf-8")
131 except subprocess.CalledProcessError as ex:
132 raise CRMDCNotFound(str(ex))
133
134 current_dc = ''
135 for line in status.split('\n'):
136 if line.startswith('Current DC'):
137 # Current DC: juju-lytrusty-machine-2 (168108163) - partition with quorum
138 current_dc = line.split(':')[1].split()[0]
139 if current_dc == get_unit_hostname():
140 return True
141 elif current_dc == 'NONE':
142 raise CRMDCNotFound('Current DC: NONE')
143
144 return False
145
146
147@retry_on_exception(5, base_delay=2,
148 exc_type=(CRMResourceNotFound, CRMDCNotFound))
149def is_crm_leader(resource, retry=False):
150 """
151 Returns True if the charm calling this is the elected corosync leader,
152 as returned by calling the external "crm" command.
153
154 We allow this operation to be retried to avoid the possibility of getting a
155 false negative. See LP #1396246 for more info.
156 """
157 if resource == DC_RESOURCE_NAME:
158 return is_crm_dc()
159 cmd = ['crm', 'resource', 'show', resource]
160 try:
161 status = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
162 if not isinstance(status, six.text_type):
163 status = six.text_type(status, "utf-8")
164 except subprocess.CalledProcessError:
165 status = None
166
167 if status and get_unit_hostname() in status:
168 return True
169
170 if status and "resource %s is NOT running" % (resource) in status:
171 raise CRMResourceNotFound("CRM resource %s not found" % (resource))
172
173 return False
174
175
176def is_leader(resource):
177 log("is_leader is deprecated. Please consider using is_crm_leader "
178 "instead.", level=WARNING)
179 return is_crm_leader(resource)
180
181
182def peer_units(peer_relation="cluster"):
183 peers = []
184 for r_id in (relation_ids(peer_relation) or []):
185 for unit in (relation_list(r_id) or []):
186 peers.append(unit)
187 return peers
188
189
190def peer_ips(peer_relation='cluster', addr_key='private-address'):
191 '''Return a dict of peers and their private-address'''
192 peers = {}
193 for r_id in relation_ids(peer_relation):
194 for unit in relation_list(r_id):
195 peers[unit] = relation_get(addr_key, rid=r_id, unit=unit)
196 return peers
197
198
199def oldest_peer(peers):
200 """Determines who the oldest peer is by comparing unit numbers."""
201 local_unit_no = int(os.getenv('JUJU_UNIT_NAME').split('/')[1])
202 for peer in peers:
203 remote_unit_no = int(peer.split('/')[1])
204 if remote_unit_no < local_unit_no:
205 return False
206 return True
207
208
209def eligible_leader(resource):
210 log("eligible_leader is deprecated. Please consider using "
211 "is_elected_leader instead.", level=WARNING)
212 return is_elected_leader(resource)
213
214
215def https():
216 '''
217 Determines whether enough data has been provided in configuration
218 or relation data to configure HTTPS
219 .
220 returns: boolean
221 '''
222 use_https = config_get('use-https')
223 if use_https and bool_from_string(use_https):
224 return True
225 if config_get('ssl_cert') and config_get('ssl_key'):
226 return True
227 for r_id in relation_ids('certificates'):
228 for unit in relation_list(r_id):
229 ca = relation_get('ca', rid=r_id, unit=unit)
230 if ca:
231 return True
232 for r_id in relation_ids('identity-service'):
233 for unit in relation_list(r_id):
234 # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN
235 rel_state = [
236 relation_get('https_keystone', rid=r_id, unit=unit),
237 relation_get('ca_cert', rid=r_id, unit=unit),
238 ]
239 # NOTE: works around (LP: #1203241)
240 if (None not in rel_state) and ('' not in rel_state):
241 return True
242 return False
243
244
245def determine_api_port(public_port, singlenode_mode=False):
246 '''
247 Determine correct API server listening port based on
248 existence of HTTPS reverse proxy and/or haproxy.
249
250 public_port: int: standard public port for given service
251
252 singlenode_mode: boolean: Shuffle ports when only a single unit is present
253
254 returns: int: the correct listening port for the API service
255 '''
256 i = 0
257 if singlenode_mode:
258 i += 1
259 elif len(peer_units()) > 0 or is_clustered():
260 i += 1
261 if https():
262 i += 1
263 return public_port - (i * 10)
264
265
266def determine_apache_port(public_port, singlenode_mode=False):
267 '''
268 Description: Determine correct apache listening port based on public IP +
269 state of the cluster.
270
271 public_port: int: standard public port for given service
272
273 singlenode_mode: boolean: Shuffle ports when only a single unit is present
274
275 returns: int: the correct listening port for the HAProxy service
276 '''
277 i = 0
278 if singlenode_mode:
279 i += 1
280 elif len(peer_units()) > 0 or is_clustered():
281 i += 1
282 return public_port - (i * 10)
283
284
285determine_apache_port_single = functools.partial(
286 determine_apache_port, singlenode_mode=True)
287
288
289def get_hacluster_config(exclude_keys=None):
290 '''
291 Obtains all relevant configuration from charm configuration required
292 for initiating a relation to hacluster:
293
294 ha-bindiface, ha-mcastport, vip, os-internal-hostname,
295 os-admin-hostname, os-public-hostname, os-access-hostname
296
297 param: exclude_keys: list of setting key(s) to be excluded.
298 returns: dict: A dict containing settings keyed by setting name.
299 raises: HAIncompleteConfig if settings are missing or incorrect.
300 '''
301 settings = ['ha-bindiface', 'ha-mcastport', 'vip', 'os-internal-hostname',
302 'os-admin-hostname', 'os-public-hostname', 'os-access-hostname']
303 conf = {}
304 for setting in settings:
305 if exclude_keys and setting in exclude_keys:
306 continue
307
308 conf[setting] = config_get(setting)
309
310 if not valid_hacluster_config():
311 raise HAIncorrectConfig('Insufficient or incorrect config data to '
312 'configure hacluster.')
313 return conf
314
315
316def valid_hacluster_config():
317 '''
318 Check that either vip or dns-ha is set. If dns-ha then one of os-*-hostname
319 must be set.
320
321 Note: ha-bindiface and ha-macastport both have defaults and will always
322 be set. We only care that either vip or dns-ha is set.
323
324 :returns: boolean: valid config returns true.
325 raises: HAIncompatibileConfig if settings conflict.
326 raises: HAIncompleteConfig if settings are missing.
327 '''
328 vip = config_get('vip')
329 dns = config_get('dns-ha')
330 if not(bool(vip) ^ bool(dns)):
331 msg = ('HA: Either vip or dns-ha must be set but not both in order to '
332 'use high availability')
333 status_set('blocked', msg)
334 raise HAIncorrectConfig(msg)
335
336 # If dns-ha then one of os-*-hostname must be set
337 if dns:
338 dns_settings = ['os-internal-hostname', 'os-admin-hostname',
339 'os-public-hostname', 'os-access-hostname']
340 # At this point it is unknown if one or all of the possible
341 # network spaces are in HA. Validate at least one is set which is
342 # the minimum required.
343 for setting in dns_settings:
344 if config_get(setting):
345 log('DNS HA: At least one hostname is set {}: {}'
346 ''.format(setting, config_get(setting)),
347 level=DEBUG)
348 return True
349
350 msg = ('DNS HA: At least one os-*-hostname(s) must be set to use '
351 'DNS HA')
352 status_set('blocked', msg)
353 raise HAIncompleteConfig(msg)
354
355 log('VIP HA: VIP is set {}'.format(vip), level=DEBUG)
356 return True
357
358
359def canonical_url(configs, vip_setting='vip'):
360 '''
361 Returns the correct HTTP URL to this host given the state of HTTPS
362 configuration and hacluster.
363
364 :configs : OSTemplateRenderer: A config tempating object to inspect for
365 a complete https context.
366
367 :vip_setting: str: Setting in charm config that specifies
368 VIP address.
369 '''
370 scheme = 'http'
371 if 'https' in configs.complete_contexts():
372 scheme = 'https'
373 if is_clustered():
374 addr = config_get(vip_setting)
375 else:
376 addr = unit_get('private-address')
377 return '%s://%s' % (scheme, addr)
378
379
380def distributed_wait(modulo=None, wait=None, operation_name='operation'):
381 ''' Distribute operations by waiting based on modulo_distribution
382
383 If modulo and or wait are not set, check config_get for those values.
384 If config values are not set, default to modulo=3 and wait=30.
385
386 :param modulo: int The modulo number creates the group distribution
387 :param wait: int The constant time wait value
388 :param operation_name: string Operation name for status message
389 i.e. 'restart'
390 :side effect: Calls config_get()
391 :side effect: Calls log()
392 :side effect: Calls status_set()
393 :side effect: Calls time.sleep()
394 '''
395 if modulo is None:
396 modulo = config_get('modulo-nodes') or 3
397 if wait is None:
398 wait = config_get('known-wait') or 30
399 if juju_is_leader():
400 # The leader should never wait
401 calculated_wait = 0
402 else:
403 # non_zero_wait=True guarantees the non-leader who gets modulo 0
404 # will still wait
405 calculated_wait = modulo_distribution(modulo=modulo, wait=wait,
406 non_zero_wait=True)
407 msg = "Waiting {} seconds for {} ...".format(calculated_wait,
408 operation_name)
409 log(msg, DEBUG)
410 status_set('maintenance', msg)
411 time.sleep(calculated_wait)
412
413
414def get_managed_services_and_ports(services, external_ports,
415 external_services=None,
416 port_conv_f=determine_apache_port_single):
417 """Get the services and ports managed by this charm.
418
419 Return only the services and corresponding ports that are managed by this
420 charm. This excludes haproxy when there is a relation with hacluster. This
421 is because this charm passes responsability for stopping and starting
422 haproxy to hacluster.
423
424 Similarly, if a relation with hacluster exists then the ports returned by
425 this method correspond to those managed by the apache server rather than
426 haproxy.
427
428 :param services: List of services.
429 :type services: List[str]
430 :param external_ports: List of ports managed by external services.
431 :type external_ports: List[int]
432 :param external_services: List of services to be removed if ha relation is
433 present.
434 :type external_services: List[str]
435 :param port_conv_f: Function to apply to ports to calculate the ports
436 managed by services controlled by this charm.
437 :type port_convert_func: f()
438 :returns: A tuple containing a list of services first followed by a list of
439 ports.
440 :rtype: Tuple[List[str], List[int]]
441 """
442 if external_services is None:
443 external_services = ['haproxy']
444 if relation_ids('ha'):
445 for svc in external_services:
446 try:
447 services.remove(svc)
448 except ValueError:
449 pass
450 external_ports = [port_conv_f(p) for p in external_ports]
451 return services, external_ports
0452
=== added directory 'hooks/charmhelpers/contrib/hardening'
=== added file 'hooks/charmhelpers/contrib/hardening/README.hardening.md'
--- hooks/charmhelpers/contrib/hardening/README.hardening.md 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/README.hardening.md 2021-05-12 04:07:51 +0000
@@ -0,0 +1,38 @@
1# Juju charm-helpers hardening library
2
3## Description
4
5This library provides multiple implementations of system and application
6hardening that conform to the standards of http://hardening.io/.
7
8Current implementations include:
9
10 * OS
11 * SSH
12 * MySQL
13 * Apache
14
15## Requirements
16
17* Juju Charms
18
19## Usage
20
211. Synchronise this library into your charm and add the harden() decorator
22 (from contrib.hardening.harden) to any functions or methods you want to use
23 to trigger hardening of your application/system.
24
252. Add a config option called 'harden' to your charm config.yaml and set it to
26 a space-delimited list of hardening modules you want to run e.g. "os ssh"
27
283. Override any config defaults (contrib.hardening.defaults) by adding a file
29 called hardening.yaml to your charm root containing the name(s) of the
30 modules whose settings you want override at root level and then any settings
31 with overrides e.g.
32
33 os:
34 general:
35 desktop_enable: True
36
374. Now just run your charm as usual and hardening will be applied each time the
38 hook runs.
039
=== added file 'hooks/charmhelpers/contrib/hardening/__init__.py'
--- hooks/charmhelpers/contrib/hardening/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,13 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
014
=== added directory 'hooks/charmhelpers/contrib/hardening/apache'
=== added file 'hooks/charmhelpers/contrib/hardening/apache/__init__.py'
--- hooks/charmhelpers/contrib/hardening/apache/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/apache/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,17 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from os import path
16
17TEMPLATES_DIR = path.join(path.dirname(__file__), 'templates')
018
=== added directory 'hooks/charmhelpers/contrib/hardening/apache/checks'
=== added file 'hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py'
--- hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/apache/checks/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,29 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from charmhelpers.core.hookenv import (
16 log,
17 DEBUG,
18)
19from charmhelpers.contrib.hardening.apache.checks import config
20
21
22def run_apache_checks():
23 log("Starting Apache hardening checks.", level=DEBUG)
24 checks = config.get_audits()
25 for check in checks:
26 log("Running '%s' check" % (check.__class__.__name__), level=DEBUG)
27 check.ensure_compliance()
28
29 log("Apache hardening checks complete.", level=DEBUG)
030
=== added file 'hooks/charmhelpers/contrib/hardening/apache/checks/config.py'
--- hooks/charmhelpers/contrib/hardening/apache/checks/config.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/apache/checks/config.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,104 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import os
16import re
17import six
18import subprocess
19
20
21from charmhelpers.core.hookenv import (
22 log,
23 INFO,
24)
25from charmhelpers.contrib.hardening.audits.file import (
26 FilePermissionAudit,
27 DirectoryPermissionAudit,
28 NoReadWriteForOther,
29 TemplatedFile,
30 DeletedFile
31)
32from charmhelpers.contrib.hardening.audits.apache import DisabledModuleAudit
33from charmhelpers.contrib.hardening.apache import TEMPLATES_DIR
34from charmhelpers.contrib.hardening import utils
35
36
37def get_audits():
38 """Get Apache hardening config audits.
39
40 :returns: dictionary of audits
41 """
42 if subprocess.call(['which', 'apache2'], stdout=subprocess.PIPE) != 0:
43 log("Apache server does not appear to be installed on this node - "
44 "skipping apache hardening", level=INFO)
45 return []
46
47 context = ApacheConfContext()
48 settings = utils.get_settings('apache')
49 audits = [
50 FilePermissionAudit(paths=os.path.join(
51 settings['common']['apache_dir'], 'apache2.conf'),
52 user='root', group='root', mode=0o0640),
53
54 TemplatedFile(os.path.join(settings['common']['apache_dir'],
55 'mods-available/alias.conf'),
56 context,
57 TEMPLATES_DIR,
58 mode=0o0640,
59 user='root',
60 service_actions=[{'service': 'apache2',
61 'actions': ['restart']}]),
62
63 TemplatedFile(os.path.join(settings['common']['apache_dir'],
64 'conf-enabled/99-hardening.conf'),
65 context,
66 TEMPLATES_DIR,
67 mode=0o0640,
68 user='root',
69 service_actions=[{'service': 'apache2',
70 'actions': ['restart']}]),
71
72 DirectoryPermissionAudit(settings['common']['apache_dir'],
73 user='root',
74 group='root',
75 mode=0o0750),
76
77 DisabledModuleAudit(settings['hardening']['modules_to_disable']),
78
79 NoReadWriteForOther(settings['common']['apache_dir']),
80
81 DeletedFile(['/var/www/html/index.html'])
82 ]
83
84 return audits
85
86
87class ApacheConfContext(object):
88 """Defines the set of key/value pairs to set in a apache config file.
89
90 This context, when called, will return a dictionary containing the
91 key/value pairs of setting to specify in the
92 /etc/apache/conf-enabled/hardening.conf file.
93 """
94 def __call__(self):
95 settings = utils.get_settings('apache')
96 ctxt = settings['hardening']
97
98 out = subprocess.check_output(['apache2', '-v'])
99 if six.PY3:
100 out = out.decode('utf-8')
101 ctxt['apache_version'] = re.search(r'.+version: Apache/(.+?)\s.+',
102 out).group(1)
103 ctxt['apache_icondir'] = '/usr/share/apache2/icons/'
104 return ctxt
0105
=== added directory 'hooks/charmhelpers/contrib/hardening/apache/templates'
=== added file 'hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf'
--- hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/apache/templates/99-hardening.conf 2021-05-12 04:07:51 +0000
@@ -0,0 +1,32 @@
1###############################################################################
2# WARNING: This configuration file is maintained by Juju. Local changes may
3# be overwritten.
4###############################################################################
5
6<Location / >
7 <LimitExcept {{ allowed_http_methods }} >
8 # http://httpd.apache.org/docs/2.4/upgrading.html
9 {% if apache_version > '2.2' -%}
10 Require all granted
11 {% else -%}
12 Order Allow,Deny
13 Deny from all
14 {% endif %}
15 </LimitExcept>
16</Location>
17
18<Directory />
19 Options -Indexes -FollowSymLinks
20 AllowOverride None
21</Directory>
22
23<Directory /var/www/>
24 Options -Indexes -FollowSymLinks
25 AllowOverride None
26</Directory>
27
28TraceEnable {{ traceenable }}
29ServerTokens {{ servertokens }}
30
31SSLHonorCipherOrder {{ honor_cipher_order }}
32SSLCipherSuite {{ cipher_suite }}
033
=== added file 'hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf'
--- hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/apache/templates/alias.conf 2021-05-12 04:07:51 +0000
@@ -0,0 +1,31 @@
1###############################################################################
2# WARNING: This configuration file is maintained by Juju. Local changes may
3# be overwritten.
4###############################################################################
5<IfModule alias_module>
6 #
7 # Aliases: Add here as many aliases as you need (with no limit). The format is
8 # Alias fakename realname
9 #
10 # Note that if you include a trailing / on fakename then the server will
11 # require it to be present in the URL. So "/icons" isn't aliased in this
12 # example, only "/icons/". If the fakename is slash-terminated, then the
13 # realname must also be slash terminated, and if the fakename omits the
14 # trailing slash, the realname must also omit it.
15 #
16 # We include the /icons/ alias for FancyIndexed directory listings. If
17 # you do not use FancyIndexing, you may comment this out.
18 #
19 Alias /icons/ "{{ apache_icondir }}/"
20
21 <Directory "{{ apache_icondir }}">
22 Options -Indexes -MultiViews -FollowSymLinks
23 AllowOverride None
24{% if apache_version == '2.4' -%}
25 Require all granted
26{% else -%}
27 Order allow,deny
28 Allow from all
29{% endif %}
30 </Directory>
31</IfModule>
032
=== added directory 'hooks/charmhelpers/contrib/hardening/audits'
=== added file 'hooks/charmhelpers/contrib/hardening/audits/__init__.py'
--- hooks/charmhelpers/contrib/hardening/audits/__init__.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/audits/__init__.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,54 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15
16class BaseAudit(object): # NO-QA
17 """Base class for hardening checks.
18
19 The lifecycle of a hardening check is to first check to see if the system
20 is in compliance for the specified check. If it is not in compliance, the
21 check method will return a value which will be supplied to the.
22 """
23 def __init__(self, *args, **kwargs):
24 self.unless = kwargs.get('unless', None)
25 super(BaseAudit, self).__init__()
26
27 def ensure_compliance(self):
28 """Checks to see if the current hardening check is in compliance or
29 not.
30
31 If the check that is performed is not in compliance, then an exception
32 should be raised.
33 """
34 pass
35
36 def _take_action(self):
37 """Determines whether to perform the action or not.
38
39 Checks whether or not an action should be taken. This is determined by
40 the truthy value for the unless parameter. If unless is a callback
41 method, it will be invoked with no parameters in order to determine
42 whether or not the action should be taken. Otherwise, the truthy value
43 of the unless attribute will determine if the action should be
44 performed.
45 """
46 # Do the action if there isn't an unless override.
47 if self.unless is None:
48 return True
49
50 # Invoke the callback if there is one.
51 if hasattr(self.unless, '__call__'):
52 return not self.unless()
53
54 return not self.unless
055
=== added file 'hooks/charmhelpers/contrib/hardening/audits/apache.py'
--- hooks/charmhelpers/contrib/hardening/audits/apache.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/audits/apache.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,105 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import re
16import subprocess
17
18import six
19
20from charmhelpers.core.hookenv import (
21 log,
22 INFO,
23 ERROR,
24)
25
26from charmhelpers.contrib.hardening.audits import BaseAudit
27
28
29class DisabledModuleAudit(BaseAudit):
30 """Audits Apache2 modules.
31
32 Determines if the apache2 modules are enabled. If the modules are enabled
33 then they are removed in the ensure_compliance.
34 """
35 def __init__(self, modules):
36 if modules is None:
37 self.modules = []
38 elif isinstance(modules, six.string_types):
39 self.modules = [modules]
40 else:
41 self.modules = modules
42
43 def ensure_compliance(self):
44 """Ensures that the modules are not loaded."""
45 if not self.modules:
46 return
47
48 try:
49 loaded_modules = self._get_loaded_modules()
50 non_compliant_modules = []
51 for module in self.modules:
52 if module in loaded_modules:
53 log("Module '%s' is enabled but should not be." %
54 (module), level=INFO)
55 non_compliant_modules.append(module)
56
57 if len(non_compliant_modules) == 0:
58 return
59
60 for module in non_compliant_modules:
61 self._disable_module(module)
62 self._restart_apache()
63 except subprocess.CalledProcessError as e:
64 log('Error occurred auditing apache module compliance. '
65 'This may have been already reported. '
66 'Output is: %s' % e.output, level=ERROR)
67
68 @staticmethod
69 def _get_loaded_modules():
70 """Returns the modules which are enabled in Apache."""
71 output = subprocess.check_output(['apache2ctl', '-M'])
72 if six.PY3:
73 output = output.decode('utf-8')
74 modules = []
75 for line in output.splitlines():
76 # Each line of the enabled module output looks like:
77 # module_name (static|shared)
78 # Plus a header line at the top of the output which is stripped
79 # out by the regex.
80 matcher = re.search(r'^ (\S*)_module (\S*)', line)
81 if matcher:
82 modules.append(matcher.group(1))
83 return modules
84
85 @staticmethod
86 def _disable_module(module):
87 """Disables the specified module in Apache."""
88 try:
89 subprocess.check_call(['a2dismod', module])
90 except subprocess.CalledProcessError as e:
91 # Note: catch error here to allow the attempt of disabling
92 # multiple modules in one go rather than failing after the
93 # first module fails.
94 log('Error occurred disabling module %s. '
95 'Output is: %s' % (module, e.output), level=ERROR)
96
97 @staticmethod
98 def _restart_apache():
99 """Restarts the apache process"""
100 subprocess.check_output(['service', 'apache2', 'restart'])
101
102 @staticmethod
103 def is_ssl_enabled():
104 """Check if SSL module is enabled or not"""
105 return 'ssl' in DisabledModuleAudit._get_loaded_modules()
0106
=== added file 'hooks/charmhelpers/contrib/hardening/audits/apt.py'
--- hooks/charmhelpers/contrib/hardening/audits/apt.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/audits/apt.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,104 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from __future__ import absolute_import # required for external apt import
16from six import string_types
17
18from charmhelpers.fetch import (
19 apt_cache,
20 apt_purge
21)
22from charmhelpers.core.hookenv import (
23 log,
24 DEBUG,
25 WARNING,
26)
27from charmhelpers.contrib.hardening.audits import BaseAudit
28from charmhelpers.fetch import ubuntu_apt_pkg as apt_pkg
29
30
31class AptConfig(BaseAudit):
32
33 def __init__(self, config, **kwargs):
34 self.config = config
35
36 def verify_config(self):
37 apt_pkg.init()
38 for cfg in self.config:
39 value = apt_pkg.config.get(cfg['key'], cfg.get('default', ''))
40 if value and value != cfg['expected']:
41 log("APT config '%s' has unexpected value '%s' "
42 "(expected='%s')" %
43 (cfg['key'], value, cfg['expected']), level=WARNING)
44
45 def ensure_compliance(self):
46 self.verify_config()
47
48
49class RestrictedPackages(BaseAudit):
50 """Class used to audit restricted packages on the system."""
51
52 def __init__(self, pkgs, **kwargs):
53 super(RestrictedPackages, self).__init__(**kwargs)
54 if isinstance(pkgs, string_types) or not hasattr(pkgs, '__iter__'):
55 self.pkgs = pkgs.split()
56 else:
57 self.pkgs = pkgs
58
59 def ensure_compliance(self):
60 cache = apt_cache()
61
62 for p in self.pkgs:
63 if p not in cache:
64 continue
65
66 pkg = cache[p]
67 if not self.is_virtual_package(pkg):
68 if not pkg.current_ver:
69 log("Package '%s' is not installed." % pkg.name,
70 level=DEBUG)
71 continue
72 else:
73 log("Restricted package '%s' is installed" % pkg.name,
74 level=WARNING)
75 self.delete_package(cache, pkg)
76 else:
77 log("Checking restricted virtual package '%s' provides" %
78 pkg.name, level=DEBUG)
79 self.delete_package(cache, pkg)
80
81 def delete_package(self, cache, pkg):
82 """Deletes the package from the system.
83
84 Deletes the package form the system, properly handling virtual
85 packages.
86
87 :param cache: the apt cache
88 :param pkg: the package to remove
89 """
90 if self.is_virtual_package(pkg):
91 log("Package '%s' appears to be virtual - purging provides" %
92 pkg.name, level=DEBUG)
93 for _p in pkg.provides_list:
94 self.delete_package(cache, _p[2].parent_pkg)
95 elif not pkg.current_ver:
96 log("Package '%s' not installed" % pkg.name, level=DEBUG)
97 return
98 else:
99 log("Purging package '%s'" % pkg.name, level=DEBUG)
100 apt_purge(pkg.name)
101
102 def is_virtual_package(self, pkg):
103 return (pkg.get('has_provides', False) and
104 not pkg.get('has_versions', False))
0105
=== added file 'hooks/charmhelpers/contrib/hardening/audits/file.py'
--- hooks/charmhelpers/contrib/hardening/audits/file.py 1970-01-01 00:00:00 +0000
+++ hooks/charmhelpers/contrib/hardening/audits/file.py 2021-05-12 04:07:51 +0000
@@ -0,0 +1,550 @@
1# Copyright 2016 Canonical Limited.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import grp
16import os
17import pwd
18import re
19
20from subprocess import (
21 CalledProcessError,
22 check_output,
23 check_call,
24)
25from traceback import format_exc
26from six import string_types
27from stat import (
28 S_ISGID,
29 S_ISUID
30)
31
32from charmhelpers.core.hookenv import (
33 log,
34 DEBUG,
35 INFO,
36 WARNING,
37 ERROR,
38)
39from charmhelpers.core import unitdata
40from charmhelpers.core.host import file_hash
41from charmhelpers.contrib.hardening.audits import BaseAudit
42from charmhelpers.contrib.hardening.templating import (
43 get_template_path,
44 render_and_write,
45)
46from charmhelpers.contrib.hardening import utils
47
48
49class BaseFileAudit(BaseAudit):
50 """Base class for file audits.
51
52 Provides api stubs for compliance check flow that must be used by any class
53 that implemented this one.
54 """
55
56 def __init__(self, paths, always_comply=False, *args, **kwargs):
57 """
58 :param paths: string path of list of paths of files we want to apply
59 compliance checks are criteria to.
60 :param always_comply: if true compliance criteria is always applied
61 else compliance is skipped for non-existent
62 paths.
63 """
64 super(BaseFileAudit, self).__init__(*args, **kwargs)
65 self.always_comply = always_comply
66 if isinstance(paths, string_types) or not hasattr(paths, '__iter__'):
67 self.paths = [paths]
68 else:
69 self.paths = paths
70
71 def ensure_compliance(self):
72 """Ensure that the all registered files comply to registered criteria.
73 """
74 for p in self.paths:
75 if os.path.exists(p):
76 if self.is_compliant(p):
77 continue
78
79 log('File %s is not in compliance.' % p, level=INFO)
80 else:
81 if not self.always_comply:
82 log("Non-existent path '%s' - skipping compliance check"
83 % (p), level=INFO)
84 continue
85
86 if self._take_action():
87 log("Applying compliance criteria to '%s'" % (p), level=INFO)
88 self.comply(p)
89
90 def is_compliant(self, path):
91 """Audits the path to see if it is compliance.
92
93 :param path: the path to the file that should be checked.
94 """
95 raise NotImplementedError
96
97 def comply(self, path):
98 """Enforces the compliance of a path.
99
100 :param path: the path to the file that should be enforced.
101 """
102 raise NotImplementedError
103
104 @classmethod
105 def _get_stat(cls, path):
106 """Returns the Posix st_stat information for the specified file path.
107
108 :param path: the path to get the st_stat information for.
109 :returns: an st_stat object for the path or None if the path doesn't
110 exist.
111 """
112 return os.stat(path)
113
114
115class FilePermissionAudit(BaseFileAudit):
116 """Implements an audit for file permissions and ownership for a user.
117
118 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