[wolsen,r=]
Resolves the amulet SSL test failure by generating the SSL certificates as part of the amulet test suite run rather than providing pre-made ssl certs. Fixes-Bug: #1436014
This commit is contained in:
parent
a5e4055a5f
commit
cb3e6741a5
1
Makefile
1
Makefile
|
@ -26,6 +26,7 @@ bin/charm_helpers_sync.py:
|
||||||
|
|
||||||
sync: bin/charm_helpers_sync.py
|
sync: bin/charm_helpers_sync.py
|
||||||
@$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers.yaml
|
@$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers.yaml
|
||||||
|
@$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-tests.yaml
|
||||||
|
|
||||||
publish: lint
|
publish: lint
|
||||||
bzr push lp:charms/rabbitmq-server
|
bzr push lp:charms/rabbitmq-server
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
destination: tests/charmhelpers
|
||||||
|
branch: lp:charm-helpers
|
||||||
|
include:
|
||||||
|
- core
|
||||||
|
- contrib.ssl
|
|
@ -6,21 +6,29 @@ import amulet
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
from charmhelpers.contrib.ssl.service import ServiceCA
|
||||||
|
|
||||||
# The number of seconds to wait for the environment to setup.
|
# The number of seconds to wait for the environment to setup.
|
||||||
seconds = 900
|
seconds = 900
|
||||||
# Get the directory in this way to load the files from the tests directory.
|
# Get the directory in this way to load the files from the tests directory.
|
||||||
path = os.path.abspath(os.path.dirname(__file__))
|
path = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
key_path = os.path.join(path, 'rabbit-server-privkey.pem')
|
# Initialize the SSL certificates to use for the test
|
||||||
|
ca_path = '/tmp/rabbit-server-ca'
|
||||||
|
ca = ServiceCA('rabbit-server-ca', ca_path)
|
||||||
|
ca.init()
|
||||||
|
ca.get_or_create_cert('rabbitmq-server')
|
||||||
|
|
||||||
|
key_path = os.path.join(ca_path, 'certs', 'rabbitmq-server.key')
|
||||||
# Read the private key file.
|
# Read the private key file.
|
||||||
with open(key_path) as f:
|
with open(key_path) as f:
|
||||||
privateKey = f.read()
|
privateKey = f.read()
|
||||||
# Read the certificate file.
|
# Read the certificate file.
|
||||||
cert_path = os.path.join(path, 'rabbit-server-cert.pem')
|
cert_path = os.path.join(ca_path, 'certs', 'rabbitmq-server.crt')
|
||||||
with open(cert_path) as f:
|
with open(cert_path) as f:
|
||||||
certificate = f.read()
|
certificate = f.read()
|
||||||
|
|
||||||
|
|
||||||
# Create a dictionary for the rabbitmq configuration.
|
# Create a dictionary for the rabbitmq configuration.
|
||||||
rabbitmq_configuration = {
|
rabbitmq_configuration = {
|
||||||
'ssl_enabled': True,
|
'ssl_enabled': True,
|
||||||
|
@ -74,7 +82,7 @@ else:
|
||||||
server_port = rabbitmq_configuration['ssl_port']
|
server_port = rabbitmq_configuration['ssl_port']
|
||||||
|
|
||||||
# Get the path to the certificate authority file.
|
# Get the path to the certificate authority file.
|
||||||
ca_cert_path = os.path.join(path, 'rabbit-server-cacert.pem')
|
ca_cert_path = os.path.join(ca_path, 'cacert.pem')
|
||||||
|
|
||||||
print('Testing ssl connection to rabbitmq-server.')
|
print('Testing ssl connection to rabbitmq-server.')
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -7,16 +7,24 @@ import os
|
||||||
import requests
|
import requests
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
from charmhelpers.contrib.ssl.service import ServiceCA
|
||||||
|
|
||||||
# The number of seconds to wait for the environment to setup.
|
# The number of seconds to wait for the environment to setup.
|
||||||
seconds = 900
|
seconds = 900
|
||||||
# Get the directory in this way to load the files from the tests directory.
|
# Get the directory in this way to load the files from the tests directory.
|
||||||
path = os.path.abspath(os.path.dirname(__file__))
|
path = os.path.abspath(os.path.dirname(__file__))
|
||||||
key_path = os.path.join(path, 'rabbit-server-privkey.pem')
|
|
||||||
|
# Initialize the SSL certificates to use for the test
|
||||||
|
ca_path = '/tmp/rabbit-server-ca'
|
||||||
|
ca = ServiceCA('rabbit-server-ca', ca_path)
|
||||||
|
ca.init()
|
||||||
|
ca.get_or_create_cert('rabbitmq-server')
|
||||||
|
|
||||||
|
key_path = os.path.join(ca_path, 'certs', 'rabbitmq-server.key')
|
||||||
# Read the private key file.
|
# Read the private key file.
|
||||||
with open(key_path) as f:
|
with open(key_path) as f:
|
||||||
privateKey = f.read()
|
privateKey = f.read()
|
||||||
cert_path = os.path.join(path, 'rabbit-server-cert.pem')
|
cert_path = os.path.join(ca_path, 'certs', 'rabbitmq-server.crt')
|
||||||
# Read the certificate file.
|
# Read the certificate file.
|
||||||
with open(cert_path) as f:
|
with open(cert_path) as f:
|
||||||
certificate = f.read()
|
certificate = f.read()
|
||||||
|
@ -116,7 +124,7 @@ rabbit_host = rabbit_unit.info['public-address']
|
||||||
ssl_port = rabbit_configuration['ssl_port']
|
ssl_port = rabbit_configuration['ssl_port']
|
||||||
|
|
||||||
# Get the path to the certificate authority file.
|
# Get the path to the certificate authority file.
|
||||||
ca_cert_path = os.path.join(path, 'rabbit-server-cacert.pem')
|
ca_cert_path = os.path.join(ca_path, 'cacert.pem')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a normal socket.
|
# Create a normal socket.
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# Bootstrap charm-helpers, installing its dependencies if necessary using
|
||||||
|
# only standard libraries.
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import six # flake8: noqa
|
||||||
|
except ImportError:
|
||||||
|
if sys.version_info.major == 2:
|
||||||
|
subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
|
||||||
|
else:
|
||||||
|
subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
|
||||||
|
import six # flake8: noqa
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml # flake8: noqa
|
||||||
|
except ImportError:
|
||||||
|
if sys.version_info.major == 2:
|
||||||
|
subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
|
||||||
|
else:
|
||||||
|
subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
|
||||||
|
import yaml # flake8: noqa
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from charmhelpers.core import hookenv
|
||||||
|
|
||||||
|
|
||||||
|
def generate_selfsigned(keyfile, certfile, keysize="1024", config=None, subject=None, cn=None):
|
||||||
|
"""Generate selfsigned SSL keypair
|
||||||
|
|
||||||
|
You must provide one of the 3 optional arguments:
|
||||||
|
config, subject or cn
|
||||||
|
If more than one is provided the leftmost will be used
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
keyfile -- (required) full path to the keyfile to be created
|
||||||
|
certfile -- (required) full path to the certfile to be created
|
||||||
|
keysize -- (optional) SSL key length
|
||||||
|
config -- (optional) openssl configuration file
|
||||||
|
subject -- (optional) dictionary with SSL subject variables
|
||||||
|
cn -- (optional) cerfificate common name
|
||||||
|
|
||||||
|
Required keys in subject dict:
|
||||||
|
cn -- Common name (eq. FQDN)
|
||||||
|
|
||||||
|
Optional keys in subject dict
|
||||||
|
country -- Country Name (2 letter code)
|
||||||
|
state -- State or Province Name (full name)
|
||||||
|
locality -- Locality Name (eg, city)
|
||||||
|
organization -- Organization Name (eg, company)
|
||||||
|
organizational_unit -- Organizational Unit Name (eg, section)
|
||||||
|
email -- Email Address
|
||||||
|
"""
|
||||||
|
|
||||||
|
cmd = []
|
||||||
|
if config:
|
||||||
|
cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
|
||||||
|
"rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
|
||||||
|
"-keyout", keyfile,
|
||||||
|
"-out", certfile, "-config", config]
|
||||||
|
elif subject:
|
||||||
|
ssl_subject = ""
|
||||||
|
if "country" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/C={}".format(subject["country"])
|
||||||
|
if "state" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/ST={}".format(subject["state"])
|
||||||
|
if "locality" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/L={}".format(subject["locality"])
|
||||||
|
if "organization" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/O={}".format(subject["organization"])
|
||||||
|
if "organizational_unit" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/OU={}".format(subject["organizational_unit"])
|
||||||
|
if "cn" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/CN={}".format(subject["cn"])
|
||||||
|
else:
|
||||||
|
hookenv.log("When using \"subject\" argument you must "
|
||||||
|
"provide \"cn\" field at very least")
|
||||||
|
return False
|
||||||
|
if "email" in subject:
|
||||||
|
ssl_subject = ssl_subject + "/emailAddress={}".format(subject["email"])
|
||||||
|
|
||||||
|
cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
|
||||||
|
"rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
|
||||||
|
"-keyout", keyfile,
|
||||||
|
"-out", certfile, "-subj", ssl_subject]
|
||||||
|
elif cn:
|
||||||
|
cmd = ["/usr/bin/openssl", "req", "-new", "-newkey",
|
||||||
|
"rsa:{}".format(keysize), "-days", "365", "-nodes", "-x509",
|
||||||
|
"-keyout", keyfile,
|
||||||
|
"-out", certfile, "-subj", "/CN={}".format(cn)]
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
hookenv.log("No config, subject or cn provided,"
|
||||||
|
"unable to generate self signed SSL certificates")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print("Execution of openssl command failed:\n{}".format(e))
|
||||||
|
return False
|
|
@ -0,0 +1,285 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from os.path import join as path_join
|
||||||
|
from os.path import exists
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger("service_ca")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
STD_CERT = "standard"
|
||||||
|
|
||||||
|
# Mysql server is fairly picky about cert creation
|
||||||
|
# and types, spec its creation separately for now.
|
||||||
|
MYSQL_CERT = "mysql"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCA(object):
|
||||||
|
|
||||||
|
default_expiry = str(365 * 2)
|
||||||
|
default_ca_expiry = str(365 * 6)
|
||||||
|
|
||||||
|
def __init__(self, name, ca_dir, cert_type=STD_CERT):
|
||||||
|
self.name = name
|
||||||
|
self.ca_dir = ca_dir
|
||||||
|
self.cert_type = cert_type
|
||||||
|
|
||||||
|
###############
|
||||||
|
# Hook Helper API
|
||||||
|
@staticmethod
|
||||||
|
def get_ca(type=STD_CERT):
|
||||||
|
service_name = os.environ['JUJU_UNIT_NAME'].split('/')[0]
|
||||||
|
ca_path = os.path.join(os.environ['CHARM_DIR'], 'ca')
|
||||||
|
ca = ServiceCA(service_name, ca_path, type)
|
||||||
|
ca.init()
|
||||||
|
return ca
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_service_cert(cls, type=STD_CERT):
|
||||||
|
service_name = os.environ['JUJU_UNIT_NAME'].split('/')[0]
|
||||||
|
ca = cls.get_ca()
|
||||||
|
crt, key = ca.get_or_create_cert(service_name)
|
||||||
|
return crt, key, ca.get_ca_bundle()
|
||||||
|
|
||||||
|
###############
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
log.debug("initializing service ca")
|
||||||
|
if not exists(self.ca_dir):
|
||||||
|
self._init_ca_dir(self.ca_dir)
|
||||||
|
self._init_ca()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ca_key(self):
|
||||||
|
return path_join(self.ca_dir, 'private', 'cacert.key')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ca_cert(self):
|
||||||
|
return path_join(self.ca_dir, 'cacert.pem')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ca_conf(self):
|
||||||
|
return path_join(self.ca_dir, 'ca.cnf')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signing_conf(self):
|
||||||
|
return path_join(self.ca_dir, 'signing.cnf')
|
||||||
|
|
||||||
|
def _init_ca_dir(self, ca_dir):
|
||||||
|
os.mkdir(ca_dir)
|
||||||
|
for i in ['certs', 'crl', 'newcerts', 'private']:
|
||||||
|
sd = path_join(ca_dir, i)
|
||||||
|
if not exists(sd):
|
||||||
|
os.mkdir(sd)
|
||||||
|
|
||||||
|
if not exists(path_join(ca_dir, 'serial')):
|
||||||
|
with open(path_join(ca_dir, 'serial'), 'wb') as fh:
|
||||||
|
fh.write(bytes('02\n', 'UTF-8'))
|
||||||
|
|
||||||
|
if not exists(path_join(ca_dir, 'index.txt')):
|
||||||
|
with open(path_join(ca_dir, 'index.txt'), 'wb') as fh:
|
||||||
|
fh.write(bytes('', 'UTF-8'))
|
||||||
|
|
||||||
|
def _init_ca(self):
|
||||||
|
"""Generate the root ca's cert and key.
|
||||||
|
"""
|
||||||
|
if not exists(path_join(self.ca_dir, 'ca.cnf')):
|
||||||
|
with open(path_join(self.ca_dir, 'ca.cnf'), 'wb') as fh:
|
||||||
|
fh.write(bytes(
|
||||||
|
CA_CONF_TEMPLATE % (self.get_conf_variables()),
|
||||||
|
'UTF-8'))
|
||||||
|
|
||||||
|
if not exists(path_join(self.ca_dir, 'signing.cnf')):
|
||||||
|
with open(path_join(self.ca_dir, 'signing.cnf'), 'wb') as fh:
|
||||||
|
fh.write(bytes(
|
||||||
|
SIGNING_CONF_TEMPLATE % (self.get_conf_variables()),
|
||||||
|
'UTF-8'))
|
||||||
|
|
||||||
|
if exists(self.ca_cert) or exists(self.ca_key):
|
||||||
|
raise RuntimeError("Initialized called when CA already exists")
|
||||||
|
cmd = ['openssl', 'req', '-config', self.ca_conf,
|
||||||
|
'-x509', '-nodes', '-newkey', 'rsa',
|
||||||
|
'-days', self.default_ca_expiry,
|
||||||
|
'-keyout', self.ca_key, '-out', self.ca_cert,
|
||||||
|
'-outform', 'PEM']
|
||||||
|
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
||||||
|
log.debug("CA Init:\n %s", output)
|
||||||
|
|
||||||
|
def get_conf_variables(self):
|
||||||
|
return dict(
|
||||||
|
org_name="juju",
|
||||||
|
org_unit_name="%s service" % self.name,
|
||||||
|
common_name=self.name,
|
||||||
|
ca_dir=self.ca_dir)
|
||||||
|
|
||||||
|
def get_or_create_cert(self, common_name):
|
||||||
|
if common_name in self:
|
||||||
|
return self.get_certificate(common_name)
|
||||||
|
return self.create_certificate(common_name)
|
||||||
|
|
||||||
|
def create_certificate(self, common_name):
|
||||||
|
if common_name in self:
|
||||||
|
return self.get_certificate(common_name)
|
||||||
|
key_p = path_join(self.ca_dir, "certs", "%s.key" % common_name)
|
||||||
|
crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
|
||||||
|
csr_p = path_join(self.ca_dir, "certs", "%s.csr" % common_name)
|
||||||
|
self._create_certificate(common_name, key_p, csr_p, crt_p)
|
||||||
|
return self.get_certificate(common_name)
|
||||||
|
|
||||||
|
def get_certificate(self, common_name):
|
||||||
|
if common_name not in self:
|
||||||
|
raise ValueError("No certificate for %s" % common_name)
|
||||||
|
key_p = path_join(self.ca_dir, "certs", "%s.key" % common_name)
|
||||||
|
crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
|
||||||
|
with open(crt_p) as fh:
|
||||||
|
crt = fh.read()
|
||||||
|
with open(key_p) as fh:
|
||||||
|
key = fh.read()
|
||||||
|
return crt, key
|
||||||
|
|
||||||
|
def __contains__(self, common_name):
|
||||||
|
crt_p = path_join(self.ca_dir, "certs", "%s.crt" % common_name)
|
||||||
|
return exists(crt_p)
|
||||||
|
|
||||||
|
def _create_certificate(self, common_name, key_p, csr_p, crt_p):
|
||||||
|
template_vars = self.get_conf_variables()
|
||||||
|
template_vars['common_name'] = common_name
|
||||||
|
subj = '/O=%(org_name)s/OU=%(org_unit_name)s/CN=%(common_name)s' % (
|
||||||
|
template_vars)
|
||||||
|
|
||||||
|
log.debug("CA Create Cert %s", common_name)
|
||||||
|
cmd = ['openssl', 'req', '-sha1', '-newkey', 'rsa:2048',
|
||||||
|
'-nodes', '-days', self.default_expiry,
|
||||||
|
'-keyout', key_p, '-out', csr_p, '-subj', subj]
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
cmd = ['openssl', 'rsa', '-in', key_p, '-out', key_p]
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
log.debug("CA Sign Cert %s", common_name)
|
||||||
|
if self.cert_type == MYSQL_CERT:
|
||||||
|
cmd = ['openssl', 'x509', '-req',
|
||||||
|
'-in', csr_p, '-days', self.default_expiry,
|
||||||
|
'-CA', self.ca_cert, '-CAkey', self.ca_key,
|
||||||
|
'-set_serial', '01', '-out', crt_p]
|
||||||
|
else:
|
||||||
|
cmd = ['openssl', 'ca', '-config', self.signing_conf,
|
||||||
|
'-extensions', 'req_extensions',
|
||||||
|
'-days', self.default_expiry, '-notext',
|
||||||
|
'-in', csr_p, '-out', crt_p, '-subj', subj, '-batch']
|
||||||
|
log.debug("running %s", " ".join(cmd))
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
def get_ca_bundle(self):
|
||||||
|
with open(self.ca_cert) as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
CA_CONF_TEMPLATE = """
|
||||||
|
[ ca ]
|
||||||
|
default_ca = CA_default
|
||||||
|
|
||||||
|
[ CA_default ]
|
||||||
|
dir = %(ca_dir)s
|
||||||
|
policy = policy_match
|
||||||
|
database = $dir/index.txt
|
||||||
|
serial = $dir/serial
|
||||||
|
certs = $dir/certs
|
||||||
|
crl_dir = $dir/crl
|
||||||
|
new_certs_dir = $dir/newcerts
|
||||||
|
certificate = $dir/cacert.pem
|
||||||
|
private_key = $dir/private/cacert.key
|
||||||
|
RANDFILE = $dir/private/.rand
|
||||||
|
default_md = default
|
||||||
|
|
||||||
|
[ req ]
|
||||||
|
default_bits = 1024
|
||||||
|
default_md = sha1
|
||||||
|
|
||||||
|
prompt = no
|
||||||
|
distinguished_name = ca_distinguished_name
|
||||||
|
|
||||||
|
x509_extensions = ca_extensions
|
||||||
|
|
||||||
|
[ ca_distinguished_name ]
|
||||||
|
organizationName = %(org_name)s
|
||||||
|
organizationalUnitName = %(org_unit_name)s Certificate Authority
|
||||||
|
|
||||||
|
|
||||||
|
[ policy_match ]
|
||||||
|
countryName = optional
|
||||||
|
stateOrProvinceName = optional
|
||||||
|
organizationName = match
|
||||||
|
organizationalUnitName = optional
|
||||||
|
commonName = supplied
|
||||||
|
|
||||||
|
[ ca_extensions ]
|
||||||
|
basicConstraints = critical,CA:true
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
authorityKeyIdentifier = keyid:always, issuer
|
||||||
|
keyUsage = cRLSign, keyCertSign
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SIGNING_CONF_TEMPLATE = """
|
||||||
|
[ ca ]
|
||||||
|
default_ca = CA_default
|
||||||
|
|
||||||
|
[ CA_default ]
|
||||||
|
dir = %(ca_dir)s
|
||||||
|
policy = policy_match
|
||||||
|
database = $dir/index.txt
|
||||||
|
serial = $dir/serial
|
||||||
|
certs = $dir/certs
|
||||||
|
crl_dir = $dir/crl
|
||||||
|
new_certs_dir = $dir/newcerts
|
||||||
|
certificate = $dir/cacert.pem
|
||||||
|
private_key = $dir/private/cacert.key
|
||||||
|
RANDFILE = $dir/private/.rand
|
||||||
|
default_md = default
|
||||||
|
|
||||||
|
[ req ]
|
||||||
|
default_bits = 1024
|
||||||
|
default_md = sha1
|
||||||
|
|
||||||
|
prompt = no
|
||||||
|
distinguished_name = req_distinguished_name
|
||||||
|
|
||||||
|
x509_extensions = req_extensions
|
||||||
|
|
||||||
|
[ req_distinguished_name ]
|
||||||
|
organizationName = %(org_name)s
|
||||||
|
organizationalUnitName = %(org_unit_name)s machine resources
|
||||||
|
commonName = %(common_name)s
|
||||||
|
|
||||||
|
[ policy_match ]
|
||||||
|
countryName = optional
|
||||||
|
stateOrProvinceName = optional
|
||||||
|
organizationName = match
|
||||||
|
organizationalUnitName = optional
|
||||||
|
commonName = supplied
|
||||||
|
|
||||||
|
[ req_extensions ]
|
||||||
|
basicConstraints = CA:false
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
authorityKeyIdentifier = keyid:always, issuer
|
||||||
|
keyUsage = digitalSignature, keyEncipherment, keyAgreement
|
||||||
|
extendedKeyUsage = serverAuth, clientAuth
|
||||||
|
"""
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2014 Canonical Ltd.
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Edward Hope-Morley <opentastic@gmail.com>
|
||||||
|
#
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from charmhelpers.core.hookenv import (
|
||||||
|
log,
|
||||||
|
INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
|
||||||
|
"""If the decorated function raises exception exc_type, allow num_retries
|
||||||
|
retry attempts before raise the exception.
|
||||||
|
"""
|
||||||
|
def _retry_on_exception_inner_1(f):
|
||||||
|
def _retry_on_exception_inner_2(*args, **kwargs):
|
||||||
|
retries = num_retries
|
||||||
|
multiplier = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except exc_type:
|
||||||
|
if not retries:
|
||||||
|
raise
|
||||||
|
|
||||||
|
delay = base_delay * multiplier
|
||||||
|
multiplier += 1
|
||||||
|
log("Retrying '%s' %d more times (delay=%s)" %
|
||||||
|
(f.__name__, retries, delay), level=INFO)
|
||||||
|
retries -= 1
|
||||||
|
if delay:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return _retry_on_exception_inner_2
|
||||||
|
|
||||||
|
return _retry_on_exception_inner_1
|
|
@ -0,0 +1,134 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
|
||||||
|
|
||||||
|
|
||||||
|
class Fstab(io.FileIO):
|
||||||
|
"""This class extends file in order to implement a file reader/writer
|
||||||
|
for file `/etc/fstab`
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Entry(object):
|
||||||
|
"""Entry class represents a non-comment line on the `/etc/fstab` file
|
||||||
|
"""
|
||||||
|
def __init__(self, device, mountpoint, filesystem,
|
||||||
|
options, d=0, p=0):
|
||||||
|
self.device = device
|
||||||
|
self.mountpoint = mountpoint
|
||||||
|
self.filesystem = filesystem
|
||||||
|
|
||||||
|
if not options:
|
||||||
|
options = "defaults"
|
||||||
|
|
||||||
|
self.options = options
|
||||||
|
self.d = int(d)
|
||||||
|
self.p = int(p)
|
||||||
|
|
||||||
|
def __eq__(self, o):
|
||||||
|
return str(self) == str(o)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{} {} {} {} {} {}".format(self.device,
|
||||||
|
self.mountpoint,
|
||||||
|
self.filesystem,
|
||||||
|
self.options,
|
||||||
|
self.d,
|
||||||
|
self.p)
|
||||||
|
|
||||||
|
DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
|
||||||
|
|
||||||
|
def __init__(self, path=None):
|
||||||
|
if path:
|
||||||
|
self._path = path
|
||||||
|
else:
|
||||||
|
self._path = self.DEFAULT_PATH
|
||||||
|
super(Fstab, self).__init__(self._path, 'rb+')
|
||||||
|
|
||||||
|
def _hydrate_entry(self, line):
|
||||||
|
# NOTE: use split with no arguments to split on any
|
||||||
|
# whitespace including tabs
|
||||||
|
return Fstab.Entry(*filter(
|
||||||
|
lambda x: x not in ('', None),
|
||||||
|
line.strip("\n").split()))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entries(self):
|
||||||
|
self.seek(0)
|
||||||
|
for line in self.readlines():
|
||||||
|
line = line.decode('us-ascii')
|
||||||
|
try:
|
||||||
|
if line.strip() and not line.strip().startswith("#"):
|
||||||
|
yield self._hydrate_entry(line)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_entry_by_attr(self, attr, value):
|
||||||
|
for entry in self.entries:
|
||||||
|
e_attr = getattr(entry, attr)
|
||||||
|
if e_attr == value:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_entry(self, entry):
|
||||||
|
if self.get_entry_by_attr('device', entry.device):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.write((str(entry) + '\n').encode('us-ascii'))
|
||||||
|
self.truncate()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def remove_entry(self, entry):
|
||||||
|
self.seek(0)
|
||||||
|
|
||||||
|
lines = [l.decode('us-ascii') for l in self.readlines()]
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.strip() and not line.strip().startswith("#"):
|
||||||
|
if self._hydrate_entry(line) == entry:
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines.remove(line)
|
||||||
|
|
||||||
|
self.seek(0)
|
||||||
|
self.write(''.join(lines).encode('us-ascii'))
|
||||||
|
self.truncate()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_by_mountpoint(cls, mountpoint, path=None):
|
||||||
|
fstab = cls(path=path)
|
||||||
|
entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
|
||||||
|
if entry:
|
||||||
|
return fstab.remove_entry(entry)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add(cls, device, mountpoint, filesystem, options=None, path=None):
|
||||||
|
return cls(path=path).add_entry(Fstab.Entry(device,
|
||||||
|
mountpoint, filesystem,
|
||||||
|
options=options))
|
|
@ -0,0 +1,607 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"Interactions with the Juju environment"
|
||||||
|
# Copyright 2013 Canonical Ltd.
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Charm Helpers Developers <juju@lists.ubuntu.com>
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import yaml
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import errno
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
|
||||||
|
import six
|
||||||
|
if not six.PY3:
|
||||||
|
from UserDict import UserDict
|
||||||
|
else:
|
||||||
|
from collections import UserDict
|
||||||
|
|
||||||
|
CRITICAL = "CRITICAL"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
WARNING = "WARNING"
|
||||||
|
INFO = "INFO"
|
||||||
|
DEBUG = "DEBUG"
|
||||||
|
MARKER = object()
|
||||||
|
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def cached(func):
|
||||||
|
"""Cache return values for multiple executions of func + args
|
||||||
|
|
||||||
|
For example::
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def unit_get(attribute):
|
||||||
|
pass
|
||||||
|
|
||||||
|
unit_get('test')
|
||||||
|
|
||||||
|
will cache the result of unit_get + 'test' for future calls.
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
global cache
|
||||||
|
key = str((func, args, kwargs))
|
||||||
|
try:
|
||||||
|
return cache[key]
|
||||||
|
except KeyError:
|
||||||
|
res = func(*args, **kwargs)
|
||||||
|
cache[key] = res
|
||||||
|
return res
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def flush(key):
|
||||||
|
"""Flushes any entries from function cache where the
|
||||||
|
key is found in the function+args """
|
||||||
|
flush_list = []
|
||||||
|
for item in cache:
|
||||||
|
if key in item:
|
||||||
|
flush_list.append(item)
|
||||||
|
for item in flush_list:
|
||||||
|
del cache[item]
|
||||||
|
|
||||||
|
|
||||||
|
def log(message, level=None):
|
||||||
|
"""Write a message to the juju log"""
|
||||||
|
command = ['juju-log']
|
||||||
|
if level:
|
||||||
|
command += ['-l', level]
|
||||||
|
if not isinstance(message, six.string_types):
|
||||||
|
message = repr(message)
|
||||||
|
command += [message]
|
||||||
|
# Missing juju-log should not cause failures in unit tests
|
||||||
|
# Send log output to stderr
|
||||||
|
try:
|
||||||
|
subprocess.call(command)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOENT:
|
||||||
|
if level:
|
||||||
|
message = "{}: {}".format(level, message)
|
||||||
|
message = "juju-log: {}".format(message)
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class Serializable(UserDict):
|
||||||
|
"""Wrapper, an object that can be serialized to yaml or json"""
|
||||||
|
|
||||||
|
def __init__(self, obj):
|
||||||
|
# wrap the object
|
||||||
|
UserDict.__init__(self)
|
||||||
|
self.data = obj
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
# See if this object has attribute.
|
||||||
|
if attr in ("json", "yaml", "data"):
|
||||||
|
return self.__dict__[attr]
|
||||||
|
# Check for attribute in wrapped object.
|
||||||
|
got = getattr(self.data, attr, MARKER)
|
||||||
|
if got is not MARKER:
|
||||||
|
return got
|
||||||
|
# Proxy to the wrapped object via dict interface.
|
||||||
|
try:
|
||||||
|
return self.data[attr]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(attr)
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
# Pickle as a standard dictionary.
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
# Unpickle into our wrapper.
|
||||||
|
self.data = state
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
"""Serialize the object to json"""
|
||||||
|
return json.dumps(self.data)
|
||||||
|
|
||||||
|
def yaml(self):
|
||||||
|
"""Serialize the object to yaml"""
|
||||||
|
return yaml.dump(self.data)
|
||||||
|
|
||||||
|
|
||||||
|
def execution_environment():
|
||||||
|
"""A convenient bundling of the current execution context"""
|
||||||
|
context = {}
|
||||||
|
context['conf'] = config()
|
||||||
|
if relation_id():
|
||||||
|
context['reltype'] = relation_type()
|
||||||
|
context['relid'] = relation_id()
|
||||||
|
context['rel'] = relation_get()
|
||||||
|
context['unit'] = local_unit()
|
||||||
|
context['rels'] = relations()
|
||||||
|
context['env'] = os.environ
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def in_relation_hook():
|
||||||
|
"""Determine whether we're running in a relation hook"""
|
||||||
|
return 'JUJU_RELATION' in os.environ
|
||||||
|
|
||||||
|
|
||||||
|
def relation_type():
|
||||||
|
"""The scope for the current relation hook"""
|
||||||
|
return os.environ.get('JUJU_RELATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
def relation_id():
|
||||||
|
"""The relation ID for the current relation hook"""
|
||||||
|
return os.environ.get('JUJU_RELATION_ID', None)
|
||||||
|
|
||||||
|
|
||||||
|
def local_unit():
|
||||||
|
"""Local unit ID"""
|
||||||
|
return os.environ['JUJU_UNIT_NAME']
|
||||||
|
|
||||||
|
|
||||||
|
def remote_unit():
|
||||||
|
"""The remote unit for the current relation hook"""
|
||||||
|
return os.environ['JUJU_REMOTE_UNIT']
|
||||||
|
|
||||||
|
|
||||||
|
def service_name():
|
||||||
|
"""The name service group this unit belongs to"""
|
||||||
|
return local_unit().split('/')[0]
|
||||||
|
|
||||||
|
|
||||||
|
def hook_name():
|
||||||
|
"""The name of the currently executing hook"""
|
||||||
|
return os.path.basename(sys.argv[0])
|
||||||
|
|
||||||
|
|
||||||
|
class Config(dict):
|
||||||
|
"""A dictionary representation of the charm's config.yaml, with some
|
||||||
|
extra features:
|
||||||
|
|
||||||
|
- See which values in the dictionary have changed since the previous hook.
|
||||||
|
- For values that have changed, see what the previous value was.
|
||||||
|
- Store arbitrary data for use in a later hook.
|
||||||
|
|
||||||
|
NOTE: Do not instantiate this object directly - instead call
|
||||||
|
``hookenv.config()``, which will return an instance of :class:`Config`.
|
||||||
|
|
||||||
|
Example usage::
|
||||||
|
|
||||||
|
>>> # inside a hook
|
||||||
|
>>> from charmhelpers.core import hookenv
|
||||||
|
>>> config = hookenv.config()
|
||||||
|
>>> config['foo']
|
||||||
|
'bar'
|
||||||
|
>>> # store a new key/value for later use
|
||||||
|
>>> config['mykey'] = 'myval'
|
||||||
|
|
||||||
|
|
||||||
|
>>> # user runs `juju set mycharm foo=baz`
|
||||||
|
>>> # now we're inside subsequent config-changed hook
|
||||||
|
>>> config = hookenv.config()
|
||||||
|
>>> config['foo']
|
||||||
|
'baz'
|
||||||
|
>>> # test to see if this val has changed since last hook
|
||||||
|
>>> config.changed('foo')
|
||||||
|
True
|
||||||
|
>>> # what was the previous value?
|
||||||
|
>>> config.previous('foo')
|
||||||
|
'bar'
|
||||||
|
>>> # keys/values that we add are preserved across hooks
|
||||||
|
>>> config['mykey']
|
||||||
|
'myval'
|
||||||
|
|
||||||
|
"""
|
||||||
|
CONFIG_FILE_NAME = '.juju-persistent-config'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kw):
|
||||||
|
super(Config, self).__init__(*args, **kw)
|
||||||
|
self.implicit_save = True
|
||||||
|
self._prev_dict = None
|
||||||
|
self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
|
||||||
|
if os.path.exists(self.path):
|
||||||
|
self.load_previous()
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
"""For regular dict lookups, check the current juju config first,
|
||||||
|
then the previous (saved) copy. This ensures that user-saved values
|
||||||
|
will be returned by a dict lookup.
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return dict.__getitem__(self, key)
|
||||||
|
except KeyError:
|
||||||
|
return (self._prev_dict or {})[key]
|
||||||
|
|
||||||
|
def keys(self):
|
||||||
|
prev_keys = []
|
||||||
|
if self._prev_dict is not None:
|
||||||
|
prev_keys = self._prev_dict.keys()
|
||||||
|
return list(set(prev_keys + list(dict.keys(self))))
|
||||||
|
|
||||||
|
def load_previous(self, path=None):
|
||||||
|
"""Load previous copy of config from disk.
|
||||||
|
|
||||||
|
In normal usage you don't need to call this method directly - it
|
||||||
|
is called automatically at object initialization.
|
||||||
|
|
||||||
|
:param path:
|
||||||
|
|
||||||
|
File path from which to load the previous config. If `None`,
|
||||||
|
config is loaded from the default location. If `path` is
|
||||||
|
specified, subsequent `save()` calls will write to the same
|
||||||
|
path.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.path = path or self.path
|
||||||
|
with open(self.path) as f:
|
||||||
|
self._prev_dict = json.load(f)
|
||||||
|
|
||||||
|
def changed(self, key):
|
||||||
|
"""Return True if the current value for this key is different from
|
||||||
|
the previous value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._prev_dict is None:
|
||||||
|
return True
|
||||||
|
return self.previous(key) != self.get(key)
|
||||||
|
|
||||||
|
def previous(self, key):
|
||||||
|
"""Return previous value for this key, or None if there
|
||||||
|
is no previous value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._prev_dict:
|
||||||
|
return self._prev_dict.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Save this config to disk.
|
||||||
|
|
||||||
|
If the charm is using the :mod:`Services Framework <services.base>`
|
||||||
|
or :meth:'@hook <Hooks.hook>' decorator, this
|
||||||
|
is called automatically at the end of successful hook execution.
|
||||||
|
Otherwise, it should be called directly by user code.
|
||||||
|
|
||||||
|
To disable automatic saves, set ``implicit_save=False`` on this
|
||||||
|
instance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._prev_dict:
|
||||||
|
for k, v in six.iteritems(self._prev_dict):
|
||||||
|
if k not in self:
|
||||||
|
self[k] = v
|
||||||
|
with open(self.path, 'w') as f:
|
||||||
|
json.dump(self, f)
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def config(scope=None):
|
||||||
|
"""Juju charm configuration"""
|
||||||
|
config_cmd_line = ['config-get']
|
||||||
|
if scope is not None:
|
||||||
|
config_cmd_line.append(scope)
|
||||||
|
config_cmd_line.append('--format=json')
|
||||||
|
try:
|
||||||
|
config_data = json.loads(
|
||||||
|
subprocess.check_output(config_cmd_line).decode('UTF-8'))
|
||||||
|
if scope is not None:
|
||||||
|
return config_data
|
||||||
|
return Config(config_data)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relation_get(attribute=None, unit=None, rid=None):
|
||||||
|
"""Get relation information"""
|
||||||
|
_args = ['relation-get', '--format=json']
|
||||||
|
if rid:
|
||||||
|
_args.append('-r')
|
||||||
|
_args.append(rid)
|
||||||
|
_args.append(attribute or '-')
|
||||||
|
if unit:
|
||||||
|
_args.append(unit)
|
||||||
|
try:
|
||||||
|
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
except CalledProcessError as e:
|
||||||
|
if e.returncode == 2:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def relation_set(relation_id=None, relation_settings=None, **kwargs):
|
||||||
|
"""Set relation information for the current unit"""
|
||||||
|
relation_settings = relation_settings if relation_settings else {}
|
||||||
|
relation_cmd_line = ['relation-set']
|
||||||
|
if relation_id is not None:
|
||||||
|
relation_cmd_line.extend(('-r', relation_id))
|
||||||
|
for k, v in (list(relation_settings.items()) + list(kwargs.items())):
|
||||||
|
if v is None:
|
||||||
|
relation_cmd_line.append('{}='.format(k))
|
||||||
|
else:
|
||||||
|
relation_cmd_line.append('{}={}'.format(k, v))
|
||||||
|
subprocess.check_call(relation_cmd_line)
|
||||||
|
# Flush cache of any relation-gets for local unit
|
||||||
|
flush(local_unit())
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relation_ids(reltype=None):
|
||||||
|
"""A list of relation_ids"""
|
||||||
|
reltype = reltype or relation_type()
|
||||||
|
relid_cmd_line = ['relation-ids', '--format=json']
|
||||||
|
if reltype is not None:
|
||||||
|
relid_cmd_line.append(reltype)
|
||||||
|
return json.loads(
|
||||||
|
subprocess.check_output(relid_cmd_line).decode('UTF-8')) or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def related_units(relid=None):
|
||||||
|
"""A list of related units"""
|
||||||
|
relid = relid or relation_id()
|
||||||
|
units_cmd_line = ['relation-list', '--format=json']
|
||||||
|
if relid is not None:
|
||||||
|
units_cmd_line.extend(('-r', relid))
|
||||||
|
return json.loads(
|
||||||
|
subprocess.check_output(units_cmd_line).decode('UTF-8')) or []
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relation_for_unit(unit=None, rid=None):
|
||||||
|
"""Get the json represenation of a unit's relation"""
|
||||||
|
unit = unit or remote_unit()
|
||||||
|
relation = relation_get(unit=unit, rid=rid)
|
||||||
|
for key in relation:
|
||||||
|
if key.endswith('-list'):
|
||||||
|
relation[key] = relation[key].split()
|
||||||
|
relation['__unit__'] = unit
|
||||||
|
return relation
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relations_for_id(relid=None):
|
||||||
|
"""Get relations of a specific relation ID"""
|
||||||
|
relation_data = []
|
||||||
|
relid = relid or relation_ids()
|
||||||
|
for unit in related_units(relid):
|
||||||
|
unit_data = relation_for_unit(unit, relid)
|
||||||
|
unit_data['__relid__'] = relid
|
||||||
|
relation_data.append(unit_data)
|
||||||
|
return relation_data
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relations_of_type(reltype=None):
|
||||||
|
"""Get relations of a specific type"""
|
||||||
|
relation_data = []
|
||||||
|
reltype = reltype or relation_type()
|
||||||
|
for relid in relation_ids(reltype):
|
||||||
|
for relation in relations_for_id(relid):
|
||||||
|
relation['__relid__'] = relid
|
||||||
|
relation_data.append(relation)
|
||||||
|
return relation_data
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def metadata():
|
||||||
|
"""Get the current charm metadata.yaml contents as a python object"""
|
||||||
|
with open(os.path.join(charm_dir(), 'metadata.yaml')) as md:
|
||||||
|
return yaml.safe_load(md)
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relation_types():
|
||||||
|
"""Get a list of relation types supported by this charm"""
|
||||||
|
rel_types = []
|
||||||
|
md = metadata()
|
||||||
|
for key in ('provides', 'requires', 'peers'):
|
||||||
|
section = md.get(key)
|
||||||
|
if section:
|
||||||
|
rel_types.extend(section.keys())
|
||||||
|
return rel_types
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def charm_name():
|
||||||
|
"""Get the name of the current charm as is specified on metadata.yaml"""
|
||||||
|
return metadata().get('name')
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def relations():
|
||||||
|
"""Get a nested dictionary of relation data for all related units"""
|
||||||
|
rels = {}
|
||||||
|
for reltype in relation_types():
|
||||||
|
relids = {}
|
||||||
|
for relid in relation_ids(reltype):
|
||||||
|
units = {local_unit(): relation_get(unit=local_unit(), rid=relid)}
|
||||||
|
for unit in related_units(relid):
|
||||||
|
reldata = relation_get(unit=unit, rid=relid)
|
||||||
|
units[unit] = reldata
|
||||||
|
relids[relid] = units
|
||||||
|
rels[reltype] = relids
|
||||||
|
return rels
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def is_relation_made(relation, keys='private-address'):
|
||||||
|
'''
|
||||||
|
Determine whether a relation is established by checking for
|
||||||
|
presence of key(s). If a list of keys is provided, they
|
||||||
|
must all be present for the relation to be identified as made
|
||||||
|
'''
|
||||||
|
if isinstance(keys, str):
|
||||||
|
keys = [keys]
|
||||||
|
for r_id in relation_ids(relation):
|
||||||
|
for unit in related_units(r_id):
|
||||||
|
context = {}
|
||||||
|
for k in keys:
|
||||||
|
context[k] = relation_get(k, rid=r_id,
|
||||||
|
unit=unit)
|
||||||
|
if None not in context.values():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_port(port, protocol="TCP"):
|
||||||
|
"""Open a service network port"""
|
||||||
|
_args = ['open-port']
|
||||||
|
_args.append('{}/{}'.format(port, protocol))
|
||||||
|
subprocess.check_call(_args)
|
||||||
|
|
||||||
|
|
||||||
|
def close_port(port, protocol="TCP"):
|
||||||
|
"""Close a service network port"""
|
||||||
|
_args = ['close-port']
|
||||||
|
_args.append('{}/{}'.format(port, protocol))
|
||||||
|
subprocess.check_call(_args)
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def unit_get(attribute):
|
||||||
|
"""Get the unit ID for the remote unit"""
|
||||||
|
_args = ['unit-get', '--format=json', attribute]
|
||||||
|
try:
|
||||||
|
return json.loads(subprocess.check_output(_args).decode('UTF-8'))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unit_private_ip():
|
||||||
|
"""Get this unit's private IP address"""
|
||||||
|
return unit_get('private-address')
|
||||||
|
|
||||||
|
|
||||||
|
class UnregisteredHookError(Exception):
|
||||||
|
"""Raised when an undefined hook is called"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Hooks(object):
|
||||||
|
"""A convenient handler for hook functions.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
hooks = Hooks()
|
||||||
|
|
||||||
|
# register a hook, taking its name from the function name
|
||||||
|
@hooks.hook()
|
||||||
|
def install():
|
||||||
|
pass # your code here
|
||||||
|
|
||||||
|
# register a hook, providing a custom hook name
|
||||||
|
@hooks.hook("config-changed")
|
||||||
|
def config_changed():
|
||||||
|
pass # your code here
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# execute a hook based on the name the program is called by
|
||||||
|
hooks.execute(sys.argv)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config_save=True):
|
||||||
|
super(Hooks, self).__init__()
|
||||||
|
self._hooks = {}
|
||||||
|
self._config_save = config_save
|
||||||
|
|
||||||
|
def register(self, name, function):
|
||||||
|
"""Register a hook"""
|
||||||
|
self._hooks[name] = function
|
||||||
|
|
||||||
|
def execute(self, args):
|
||||||
|
"""Execute a registered hook based on args[0]"""
|
||||||
|
hook_name = os.path.basename(args[0])
|
||||||
|
if hook_name in self._hooks:
|
||||||
|
self._hooks[hook_name]()
|
||||||
|
if self._config_save:
|
||||||
|
cfg = config()
|
||||||
|
if cfg.implicit_save:
|
||||||
|
cfg.save()
|
||||||
|
else:
|
||||||
|
raise UnregisteredHookError(hook_name)
|
||||||
|
|
||||||
|
def hook(self, *hook_names):
|
||||||
|
"""Decorator, registering them as hooks"""
|
||||||
|
def wrapper(decorated):
|
||||||
|
for hook_name in hook_names:
|
||||||
|
self.register(hook_name, decorated)
|
||||||
|
else:
|
||||||
|
self.register(decorated.__name__, decorated)
|
||||||
|
if '_' in decorated.__name__:
|
||||||
|
self.register(
|
||||||
|
decorated.__name__.replace('_', '-'), decorated)
|
||||||
|
return decorated
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def charm_dir():
|
||||||
|
"""Return the root directory of the current charm"""
|
||||||
|
return os.environ.get('CHARM_DIR')
|
||||||
|
|
||||||
|
|
||||||
|
@cached
|
||||||
|
def action_get(key=None):
|
||||||
|
"""Gets the value of an action parameter, or all key/value param pairs"""
|
||||||
|
cmd = ['action-get']
|
||||||
|
if key is not None:
|
||||||
|
cmd.append(key)
|
||||||
|
cmd.append('--format=json')
|
||||||
|
action_data = json.loads(subprocess.check_output(cmd).decode('UTF-8'))
|
||||||
|
return action_data
|
||||||
|
|
||||||
|
|
||||||
|
def action_set(values):
|
||||||
|
"""Sets the values to be returned after the action finishes"""
|
||||||
|
cmd = ['action-set']
|
||||||
|
for k, v in list(values.items()):
|
||||||
|
cmd.append('{}={}'.format(k, v))
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def action_fail(message):
|
||||||
|
"""Sets the action status to failed and sets the error message.
|
||||||
|
|
||||||
|
The results set by action_set are preserved."""
|
||||||
|
subprocess.check_call(['action-fail', message])
|
|
@ -0,0 +1,450 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Tools for working with the host system"""
|
||||||
|
# Copyright 2012 Canonical Ltd.
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Nick Moffitt <nick.moffitt@canonical.com>
|
||||||
|
# Matthew Wedgwood <matthew.wedgwood@canonical.com>
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import pwd
|
||||||
|
import grp
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import subprocess
|
||||||
|
import hashlib
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
from .hookenv import log
|
||||||
|
from .fstab import Fstab
|
||||||
|
|
||||||
|
|
||||||
|
def service_start(service_name):
|
||||||
|
"""Start a system service"""
|
||||||
|
return service('start', service_name)
|
||||||
|
|
||||||
|
|
||||||
|
def service_stop(service_name):
|
||||||
|
"""Stop a system service"""
|
||||||
|
return service('stop', service_name)
|
||||||
|
|
||||||
|
|
||||||
|
def service_restart(service_name):
|
||||||
|
"""Restart a system service"""
|
||||||
|
return service('restart', service_name)
|
||||||
|
|
||||||
|
|
||||||
|
def service_reload(service_name, restart_on_failure=False):
|
||||||
|
"""Reload a system service, optionally falling back to restart if
|
||||||
|
reload fails"""
|
||||||
|
service_result = service('reload', service_name)
|
||||||
|
if not service_result and restart_on_failure:
|
||||||
|
service_result = service('restart', service_name)
|
||||||
|
return service_result
|
||||||
|
|
||||||
|
|
||||||
|
def service(action, service_name):
|
||||||
|
"""Control a system service"""
|
||||||
|
cmd = ['service', service_name, action]
|
||||||
|
return subprocess.call(cmd) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def service_running(service):
|
||||||
|
"""Determine whether a system service is running"""
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
['service', service, 'status'],
|
||||||
|
stderr=subprocess.STDOUT).decode('UTF-8')
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if ("start/running" in output or "is running" in output):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def service_available(service_name):
|
||||||
|
"""Determine whether a system service is available"""
|
||||||
|
try:
|
||||||
|
subprocess.check_output(
|
||||||
|
['service', service_name, 'status'],
|
||||||
|
stderr=subprocess.STDOUT).decode('UTF-8')
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
return 'unrecognized service' not in e.output
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def adduser(username, password=None, shell='/bin/bash', system_user=False):
|
||||||
|
"""Add a user to the system"""
|
||||||
|
try:
|
||||||
|
user_info = pwd.getpwnam(username)
|
||||||
|
log('user {0} already exists!'.format(username))
|
||||||
|
except KeyError:
|
||||||
|
log('creating user {0}'.format(username))
|
||||||
|
cmd = ['useradd']
|
||||||
|
if system_user or password is None:
|
||||||
|
cmd.append('--system')
|
||||||
|
else:
|
||||||
|
cmd.extend([
|
||||||
|
'--create-home',
|
||||||
|
'--shell', shell,
|
||||||
|
'--password', password,
|
||||||
|
])
|
||||||
|
cmd.append(username)
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
user_info = pwd.getpwnam(username)
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
|
||||||
|
def add_group(group_name, system_group=False):
|
||||||
|
"""Add a group to the system"""
|
||||||
|
try:
|
||||||
|
group_info = grp.getgrnam(group_name)
|
||||||
|
log('group {0} already exists!'.format(group_name))
|
||||||
|
except KeyError:
|
||||||
|
log('creating group {0}'.format(group_name))
|
||||||
|
cmd = ['addgroup']
|
||||||
|
if system_group:
|
||||||
|
cmd.append('--system')
|
||||||
|
else:
|
||||||
|
cmd.extend([
|
||||||
|
'--group',
|
||||||
|
])
|
||||||
|
cmd.append(group_name)
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
group_info = grp.getgrnam(group_name)
|
||||||
|
return group_info
|
||||||
|
|
||||||
|
|
||||||
|
def add_user_to_group(username, group):
|
||||||
|
"""Add a user to a group"""
|
||||||
|
cmd = [
|
||||||
|
'gpasswd', '-a',
|
||||||
|
username,
|
||||||
|
group
|
||||||
|
]
|
||||||
|
log("Adding user {} to group {}".format(username, group))
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def rsync(from_path, to_path, flags='-r', options=None):
|
||||||
|
"""Replicate the contents of a path"""
|
||||||
|
options = options or ['--delete', '--executability']
|
||||||
|
cmd = ['/usr/bin/rsync', flags]
|
||||||
|
cmd.extend(options)
|
||||||
|
cmd.append(from_path)
|
||||||
|
cmd.append(to_path)
|
||||||
|
log(" ".join(cmd))
|
||||||
|
return subprocess.check_output(cmd).decode('UTF-8').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def symlink(source, destination):
|
||||||
|
"""Create a symbolic link"""
|
||||||
|
log("Symlinking {} as {}".format(source, destination))
|
||||||
|
cmd = [
|
||||||
|
'ln',
|
||||||
|
'-sf',
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
]
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def mkdir(path, owner='root', group='root', perms=0o555, force=False):
|
||||||
|
"""Create a directory"""
|
||||||
|
log("Making dir {} {}:{} {:o}".format(path, owner, group,
|
||||||
|
perms))
|
||||||
|
uid = pwd.getpwnam(owner).pw_uid
|
||||||
|
gid = grp.getgrnam(group).gr_gid
|
||||||
|
realpath = os.path.abspath(path)
|
||||||
|
path_exists = os.path.exists(realpath)
|
||||||
|
if path_exists and force:
|
||||||
|
if not os.path.isdir(realpath):
|
||||||
|
log("Removing non-directory file {} prior to mkdir()".format(path))
|
||||||
|
os.unlink(realpath)
|
||||||
|
os.makedirs(realpath, perms)
|
||||||
|
elif not path_exists:
|
||||||
|
os.makedirs(realpath, perms)
|
||||||
|
os.chown(realpath, uid, gid)
|
||||||
|
os.chmod(realpath, perms)
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path, content, owner='root', group='root', perms=0o444):
|
||||||
|
"""Create or overwrite a file with the contents of a byte string."""
|
||||||
|
log("Writing file {} {}:{} {:o}".format(path, owner, group, perms))
|
||||||
|
uid = pwd.getpwnam(owner).pw_uid
|
||||||
|
gid = grp.getgrnam(group).gr_gid
|
||||||
|
with open(path, 'wb') as target:
|
||||||
|
os.fchown(target.fileno(), uid, gid)
|
||||||
|
os.fchmod(target.fileno(), perms)
|
||||||
|
target.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def fstab_remove(mp):
|
||||||
|
"""Remove the given mountpoint entry from /etc/fstab
|
||||||
|
"""
|
||||||
|
return Fstab.remove_by_mountpoint(mp)
|
||||||
|
|
||||||
|
|
||||||
|
def fstab_add(dev, mp, fs, options=None):
|
||||||
|
"""Adds the given device entry to the /etc/fstab file
|
||||||
|
"""
|
||||||
|
return Fstab.add(dev, mp, fs, options=options)
|
||||||
|
|
||||||
|
|
||||||
|
def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
|
||||||
|
"""Mount a filesystem at a particular mountpoint"""
|
||||||
|
cmd_args = ['mount']
|
||||||
|
if options is not None:
|
||||||
|
cmd_args.extend(['-o', options])
|
||||||
|
cmd_args.extend([device, mountpoint])
|
||||||
|
try:
|
||||||
|
subprocess.check_output(cmd_args)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
return fstab_add(device, mountpoint, filesystem, options=options)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def umount(mountpoint, persist=False):
|
||||||
|
"""Unmount a filesystem"""
|
||||||
|
cmd_args = ['umount', mountpoint]
|
||||||
|
try:
|
||||||
|
subprocess.check_output(cmd_args)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
log('Error unmounting {}\n{}'.format(mountpoint, e.output))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
return fstab_remove(mountpoint)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mounts():
|
||||||
|
"""Get a list of all mounted volumes as [[mountpoint,device],[...]]"""
|
||||||
|
with open('/proc/mounts') as f:
|
||||||
|
# [['/mount/point','/dev/path'],[...]]
|
||||||
|
system_mounts = [m[1::-1] for m in [l.strip().split()
|
||||||
|
for l in f.readlines()]]
|
||||||
|
return system_mounts
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path, hash_type='md5'):
|
||||||
|
"""
|
||||||
|
Generate a hash checksum of the contents of 'path' or None if not found.
|
||||||
|
|
||||||
|
:param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
|
||||||
|
such as md5, sha1, sha256, sha512, etc.
|
||||||
|
"""
|
||||||
|
if os.path.exists(path):
|
||||||
|
h = getattr(hashlib, hash_type)()
|
||||||
|
with open(path, 'rb') as source:
|
||||||
|
h.update(source.read())
|
||||||
|
return h.hexdigest()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_hash(path, checksum, hash_type='md5'):
|
||||||
|
"""
|
||||||
|
Validate a file using a cryptographic checksum.
|
||||||
|
|
||||||
|
:param str checksum: Value of the checksum used to validate the file.
|
||||||
|
:param str hash_type: Hash algorithm used to generate `checksum`.
|
||||||
|
Can be any hash alrgorithm supported by :mod:`hashlib`,
|
||||||
|
such as md5, sha1, sha256, sha512, etc.
|
||||||
|
:raises ChecksumError: If the file fails the checksum
|
||||||
|
|
||||||
|
"""
|
||||||
|
actual_checksum = file_hash(path, hash_type)
|
||||||
|
if checksum != actual_checksum:
|
||||||
|
raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
|
||||||
|
|
||||||
|
|
||||||
|
class ChecksumError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def restart_on_change(restart_map, stopstart=False):
|
||||||
|
"""Restart services based on configuration files changing
|
||||||
|
|
||||||
|
This function is used a decorator, for example::
|
||||||
|
|
||||||
|
@restart_on_change({
|
||||||
|
'/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
|
||||||
|
})
|
||||||
|
def ceph_client_changed():
|
||||||
|
pass # your code here
|
||||||
|
|
||||||
|
In this example, the cinder-api and cinder-volume services
|
||||||
|
would be restarted if /etc/ceph/ceph.conf is changed by the
|
||||||
|
ceph_client_changed function.
|
||||||
|
"""
|
||||||
|
def wrap(f):
|
||||||
|
def wrapped_f(*args, **kwargs):
|
||||||
|
checksums = {}
|
||||||
|
for path in restart_map:
|
||||||
|
checksums[path] = file_hash(path)
|
||||||
|
f(*args, **kwargs)
|
||||||
|
restarts = []
|
||||||
|
for path in restart_map:
|
||||||
|
if checksums[path] != file_hash(path):
|
||||||
|
restarts += restart_map[path]
|
||||||
|
services_list = list(OrderedDict.fromkeys(restarts))
|
||||||
|
if not stopstart:
|
||||||
|
for service_name in services_list:
|
||||||
|
service('restart', service_name)
|
||||||
|
else:
|
||||||
|
for action in ['stop', 'start']:
|
||||||
|
for service_name in services_list:
|
||||||
|
service(action, service_name)
|
||||||
|
return wrapped_f
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
def lsb_release():
|
||||||
|
"""Return /etc/lsb-release in a dict"""
|
||||||
|
d = {}
|
||||||
|
with open('/etc/lsb-release', 'r') as lsb:
|
||||||
|
for l in lsb:
|
||||||
|
k, v = l.split('=')
|
||||||
|
d[k.strip()] = v.strip()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def pwgen(length=None):
|
||||||
|
"""Generate a random pasword."""
|
||||||
|
if length is None:
|
||||||
|
# A random length is ok to use a weak PRNG
|
||||||
|
length = random.choice(range(35, 45))
|
||||||
|
alphanumeric_chars = [
|
||||||
|
l for l in (string.ascii_letters + string.digits)
|
||||||
|
if l not in 'l0QD1vAEIOUaeiou']
|
||||||
|
# Use a crypto-friendly PRNG (e.g. /dev/urandom) for making the
|
||||||
|
# actual password
|
||||||
|
random_generator = random.SystemRandom()
|
||||||
|
random_chars = [
|
||||||
|
random_generator.choice(alphanumeric_chars) for _ in range(length)]
|
||||||
|
return(''.join(random_chars))
|
||||||
|
|
||||||
|
|
||||||
|
def list_nics(nic_type):
|
||||||
|
'''Return a list of nics of given type(s)'''
|
||||||
|
if isinstance(nic_type, six.string_types):
|
||||||
|
int_types = [nic_type]
|
||||||
|
else:
|
||||||
|
int_types = nic_type
|
||||||
|
interfaces = []
|
||||||
|
for int_type in int_types:
|
||||||
|
cmd = ['ip', 'addr', 'show', 'label', int_type + '*']
|
||||||
|
ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
|
||||||
|
ip_output = (line for line in ip_output if line)
|
||||||
|
for line in ip_output:
|
||||||
|
if line.split()[1].startswith(int_type):
|
||||||
|
matched = re.search('.*: (' + int_type + r'[0-9]+\.[0-9]+)@.*', line)
|
||||||
|
if matched:
|
||||||
|
interface = matched.groups()[0]
|
||||||
|
else:
|
||||||
|
interface = line.split()[1].replace(":", "")
|
||||||
|
interfaces.append(interface)
|
||||||
|
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
def set_nic_mtu(nic, mtu):
|
||||||
|
'''Set MTU on a network interface'''
|
||||||
|
cmd = ['ip', 'link', 'set', nic, 'mtu', mtu]
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nic_mtu(nic):
|
||||||
|
cmd = ['ip', 'addr', 'show', nic]
|
||||||
|
ip_output = subprocess.check_output(cmd).decode('UTF-8').split('\n')
|
||||||
|
mtu = ""
|
||||||
|
for line in ip_output:
|
||||||
|
words = line.split()
|
||||||
|
if 'mtu' in words:
|
||||||
|
mtu = words[words.index("mtu") + 1]
|
||||||
|
return mtu
|
||||||
|
|
||||||
|
|
||||||
|
def get_nic_hwaddr(nic):
|
||||||
|
cmd = ['ip', '-o', '-0', 'addr', 'show', nic]
|
||||||
|
ip_output = subprocess.check_output(cmd).decode('UTF-8')
|
||||||
|
hwaddr = ""
|
||||||
|
words = ip_output.split()
|
||||||
|
if 'link/ether' in words:
|
||||||
|
hwaddr = words[words.index('link/ether') + 1]
|
||||||
|
return hwaddr
|
||||||
|
|
||||||
|
|
||||||
|
def cmp_pkgrevno(package, revno, pkgcache=None):
|
||||||
|
'''Compare supplied revno with the revno of the installed package
|
||||||
|
|
||||||
|
* 1 => Installed revno is greater than supplied arg
|
||||||
|
* 0 => Installed revno is the same as supplied arg
|
||||||
|
* -1 => Installed revno is less than supplied arg
|
||||||
|
|
||||||
|
This function imports apt_cache function from charmhelpers.fetch if
|
||||||
|
the pkgcache argument is None. Be sure to add charmhelpers.fetch if
|
||||||
|
you call this function, or pass an apt_pkg.Cache() instance.
|
||||||
|
'''
|
||||||
|
import apt_pkg
|
||||||
|
if not pkgcache:
|
||||||
|
from charmhelpers.fetch import apt_cache
|
||||||
|
pkgcache = apt_cache()
|
||||||
|
pkg = pkgcache[package]
|
||||||
|
return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def chdir(d):
|
||||||
|
cur = os.getcwd()
|
||||||
|
try:
|
||||||
|
yield os.chdir(d)
|
||||||
|
finally:
|
||||||
|
os.chdir(cur)
|
||||||
|
|
||||||
|
|
||||||
|
def chownr(path, owner, group, follow_links=True):
|
||||||
|
uid = pwd.getpwnam(owner).pw_uid
|
||||||
|
gid = grp.getgrnam(group).gr_gid
|
||||||
|
if follow_links:
|
||||||
|
chown = os.chown
|
||||||
|
else:
|
||||||
|
chown = os.lchown
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
for name in dirs + files:
|
||||||
|
full = os.path.join(root, name)
|
||||||
|
broken_symlink = os.path.lexists(full) and not os.path.exists(full)
|
||||||
|
if not broken_symlink:
|
||||||
|
chown(full, uid, gid)
|
||||||
|
|
||||||
|
|
||||||
|
def lchownr(path, owner, group):
|
||||||
|
chownr(path, owner, group, follow_links=False)
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .base import * # NOQA
|
||||||
|
from .helpers import * # NOQA
|
|
@ -0,0 +1,329 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from collections import Iterable
|
||||||
|
|
||||||
|
from charmhelpers.core import host
|
||||||
|
from charmhelpers.core import hookenv
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['ServiceManager', 'ManagerCallback',
|
||||||
|
'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
|
||||||
|
'service_restart', 'service_stop']
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceManager(object):
|
||||||
|
def __init__(self, services=None):
|
||||||
|
"""
|
||||||
|
Register a list of services, given their definitions.
|
||||||
|
|
||||||
|
Service definitions are dicts in the following formats (all keys except
|
||||||
|
'service' are optional)::
|
||||||
|
|
||||||
|
{
|
||||||
|
"service": <service name>,
|
||||||
|
"required_data": <list of required data contexts>,
|
||||||
|
"provided_data": <list of provided data contexts>,
|
||||||
|
"data_ready": <one or more callbacks>,
|
||||||
|
"data_lost": <one or more callbacks>,
|
||||||
|
"start": <one or more callbacks>,
|
||||||
|
"stop": <one or more callbacks>,
|
||||||
|
"ports": <list of ports to manage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
The 'required_data' list should contain dicts of required data (or
|
||||||
|
dependency managers that act like dicts and know how to collect the data).
|
||||||
|
Only when all items in the 'required_data' list are populated are the list
|
||||||
|
of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
|
||||||
|
information.
|
||||||
|
|
||||||
|
The 'provided_data' list should contain relation data providers, most likely
|
||||||
|
a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
|
||||||
|
that will indicate a set of data to set on a given relation.
|
||||||
|
|
||||||
|
The 'data_ready' value should be either a single callback, or a list of
|
||||||
|
callbacks, to be called when all items in 'required_data' pass `is_ready()`.
|
||||||
|
Each callback will be called with the service name as the only parameter.
|
||||||
|
After all of the 'data_ready' callbacks are called, the 'start' callbacks
|
||||||
|
are fired.
|
||||||
|
|
||||||
|
The 'data_lost' value should be either a single callback, or a list of
|
||||||
|
callbacks, to be called when a 'required_data' item no longer passes
|
||||||
|
`is_ready()`. Each callback will be called with the service name as the
|
||||||
|
only parameter. After all of the 'data_lost' callbacks are called,
|
||||||
|
the 'stop' callbacks are fired.
|
||||||
|
|
||||||
|
The 'start' value should be either a single callback, or a list of
|
||||||
|
callbacks, to be called when starting the service, after the 'data_ready'
|
||||||
|
callbacks are complete. Each callback will be called with the service
|
||||||
|
name as the only parameter. This defaults to
|
||||||
|
`[host.service_start, services.open_ports]`.
|
||||||
|
|
||||||
|
The 'stop' value should be either a single callback, or a list of
|
||||||
|
callbacks, to be called when stopping the service. If the service is
|
||||||
|
being stopped because it no longer has all of its 'required_data', this
|
||||||
|
will be called after all of the 'data_lost' callbacks are complete.
|
||||||
|
Each callback will be called with the service name as the only parameter.
|
||||||
|
This defaults to `[services.close_ports, host.service_stop]`.
|
||||||
|
|
||||||
|
The 'ports' value should be a list of ports to manage. The default
|
||||||
|
'start' handler will open the ports after the service is started,
|
||||||
|
and the default 'stop' handler will close the ports prior to stopping
|
||||||
|
the service.
|
||||||
|
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
The following registers an Upstart service called bingod that depends on
|
||||||
|
a mongodb relation and which runs a custom `db_migrate` function prior to
|
||||||
|
restarting the service, and a Runit service called spadesd::
|
||||||
|
|
||||||
|
manager = services.ServiceManager([
|
||||||
|
{
|
||||||
|
'service': 'bingod',
|
||||||
|
'ports': [80, 443],
|
||||||
|
'required_data': [MongoRelation(), config(), {'my': 'data'}],
|
||||||
|
'data_ready': [
|
||||||
|
services.template(source='bingod.conf'),
|
||||||
|
services.template(source='bingod.ini',
|
||||||
|
target='/etc/bingod.ini',
|
||||||
|
owner='bingo', perms=0400),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'service': 'spadesd',
|
||||||
|
'data_ready': services.template(source='spadesd_run.j2',
|
||||||
|
target='/etc/sv/spadesd/run',
|
||||||
|
perms=0555),
|
||||||
|
'start': runit_start,
|
||||||
|
'stop': runit_stop,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
manager.manage()
|
||||||
|
"""
|
||||||
|
self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
|
||||||
|
self._ready = None
|
||||||
|
self.services = {}
|
||||||
|
for service in services or []:
|
||||||
|
service_name = service['service']
|
||||||
|
self.services[service_name] = service
|
||||||
|
|
||||||
|
def manage(self):
|
||||||
|
"""
|
||||||
|
Handle the current hook by doing The Right Thing with the registered services.
|
||||||
|
"""
|
||||||
|
hook_name = hookenv.hook_name()
|
||||||
|
if hook_name == 'stop':
|
||||||
|
self.stop_services()
|
||||||
|
else:
|
||||||
|
self.provide_data()
|
||||||
|
self.reconfigure_services()
|
||||||
|
cfg = hookenv.config()
|
||||||
|
if cfg.implicit_save:
|
||||||
|
cfg.save()
|
||||||
|
|
||||||
|
def provide_data(self):
|
||||||
|
"""
|
||||||
|
Set the relation data for each provider in the ``provided_data`` list.
|
||||||
|
|
||||||
|
A provider must have a `name` attribute, which indicates which relation
|
||||||
|
to set data on, and a `provide_data()` method, which returns a dict of
|
||||||
|
data to set.
|
||||||
|
"""
|
||||||
|
hook_name = hookenv.hook_name()
|
||||||
|
for service in self.services.values():
|
||||||
|
for provider in service.get('provided_data', []):
|
||||||
|
if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name):
|
||||||
|
data = provider.provide_data()
|
||||||
|
_ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data
|
||||||
|
if _ready:
|
||||||
|
hookenv.relation_set(None, data)
|
||||||
|
|
||||||
|
def reconfigure_services(self, *service_names):
|
||||||
|
"""
|
||||||
|
Update all files for one or more registered services, and,
|
||||||
|
if ready, optionally restart them.
|
||||||
|
|
||||||
|
If no service names are given, reconfigures all registered services.
|
||||||
|
"""
|
||||||
|
for service_name in service_names or self.services.keys():
|
||||||
|
if self.is_ready(service_name):
|
||||||
|
self.fire_event('data_ready', service_name)
|
||||||
|
self.fire_event('start', service_name, default=[
|
||||||
|
service_restart,
|
||||||
|
manage_ports])
|
||||||
|
self.save_ready(service_name)
|
||||||
|
else:
|
||||||
|
if self.was_ready(service_name):
|
||||||
|
self.fire_event('data_lost', service_name)
|
||||||
|
self.fire_event('stop', service_name, default=[
|
||||||
|
manage_ports,
|
||||||
|
service_stop])
|
||||||
|
self.save_lost(service_name)
|
||||||
|
|
||||||
|
def stop_services(self, *service_names):
|
||||||
|
"""
|
||||||
|
Stop one or more registered services, by name.
|
||||||
|
|
||||||
|
If no service names are given, stops all registered services.
|
||||||
|
"""
|
||||||
|
for service_name in service_names or self.services.keys():
|
||||||
|
self.fire_event('stop', service_name, default=[
|
||||||
|
manage_ports,
|
||||||
|
service_stop])
|
||||||
|
|
||||||
|
def get_service(self, service_name):
|
||||||
|
"""
|
||||||
|
Given the name of a registered service, return its service definition.
|
||||||
|
"""
|
||||||
|
service = self.services.get(service_name)
|
||||||
|
if not service:
|
||||||
|
raise KeyError('Service not registered: %s' % service_name)
|
||||||
|
return service
|
||||||
|
|
||||||
|
def fire_event(self, event_name, service_name, default=None):
|
||||||
|
"""
|
||||||
|
Fire a data_ready, data_lost, start, or stop event on a given service.
|
||||||
|
"""
|
||||||
|
service = self.get_service(service_name)
|
||||||
|
callbacks = service.get(event_name, default)
|
||||||
|
if not callbacks:
|
||||||
|
return
|
||||||
|
if not isinstance(callbacks, Iterable):
|
||||||
|
callbacks = [callbacks]
|
||||||
|
for callback in callbacks:
|
||||||
|
if isinstance(callback, ManagerCallback):
|
||||||
|
callback(self, service_name, event_name)
|
||||||
|
else:
|
||||||
|
callback(service_name)
|
||||||
|
|
||||||
|
def is_ready(self, service_name):
|
||||||
|
"""
|
||||||
|
Determine if a registered service is ready, by checking its 'required_data'.
|
||||||
|
|
||||||
|
A 'required_data' item can be any mapping type, and is considered ready
|
||||||
|
if `bool(item)` evaluates as True.
|
||||||
|
"""
|
||||||
|
service = self.get_service(service_name)
|
||||||
|
reqs = service.get('required_data', [])
|
||||||
|
return all(bool(req) for req in reqs)
|
||||||
|
|
||||||
|
def _load_ready_file(self):
|
||||||
|
if self._ready is not None:
|
||||||
|
return
|
||||||
|
if os.path.exists(self._ready_file):
|
||||||
|
with open(self._ready_file) as fp:
|
||||||
|
self._ready = set(json.load(fp))
|
||||||
|
else:
|
||||||
|
self._ready = set()
|
||||||
|
|
||||||
|
def _save_ready_file(self):
|
||||||
|
if self._ready is None:
|
||||||
|
return
|
||||||
|
with open(self._ready_file, 'w') as fp:
|
||||||
|
json.dump(list(self._ready), fp)
|
||||||
|
|
||||||
|
def save_ready(self, service_name):
|
||||||
|
"""
|
||||||
|
Save an indicator that the given service is now data_ready.
|
||||||
|
"""
|
||||||
|
self._load_ready_file()
|
||||||
|
self._ready.add(service_name)
|
||||||
|
self._save_ready_file()
|
||||||
|
|
||||||
|
def save_lost(self, service_name):
|
||||||
|
"""
|
||||||
|
Save an indicator that the given service is no longer data_ready.
|
||||||
|
"""
|
||||||
|
self._load_ready_file()
|
||||||
|
self._ready.discard(service_name)
|
||||||
|
self._save_ready_file()
|
||||||
|
|
||||||
|
def was_ready(self, service_name):
|
||||||
|
"""
|
||||||
|
Determine if the given service was previously data_ready.
|
||||||
|
"""
|
||||||
|
self._load_ready_file()
|
||||||
|
return service_name in self._ready
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerCallback(object):
|
||||||
|
"""
|
||||||
|
Special case of a callback that takes the `ServiceManager` instance
|
||||||
|
in addition to the service name.
|
||||||
|
|
||||||
|
Subclasses should implement `__call__` which should accept three parameters:
|
||||||
|
|
||||||
|
* `manager` The `ServiceManager` instance
|
||||||
|
* `service_name` The name of the service it's being triggered for
|
||||||
|
* `event_name` The name of the event that this callback is handling
|
||||||
|
"""
|
||||||
|
def __call__(self, manager, service_name, event_name):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class PortManagerCallback(ManagerCallback):
|
||||||
|
"""
|
||||||
|
Callback class that will open or close ports, for use as either
|
||||||
|
a start or stop action.
|
||||||
|
"""
|
||||||
|
def __call__(self, manager, service_name, event_name):
|
||||||
|
service = manager.get_service(service_name)
|
||||||
|
new_ports = service.get('ports', [])
|
||||||
|
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
|
||||||
|
if os.path.exists(port_file):
|
||||||
|
with open(port_file) as fp:
|
||||||
|
old_ports = fp.read().split(',')
|
||||||
|
for old_port in old_ports:
|
||||||
|
if bool(old_port):
|
||||||
|
old_port = int(old_port)
|
||||||
|
if old_port not in new_ports:
|
||||||
|
hookenv.close_port(old_port)
|
||||||
|
with open(port_file, 'w') as fp:
|
||||||
|
fp.write(','.join(str(port) for port in new_ports))
|
||||||
|
for port in new_ports:
|
||||||
|
if event_name == 'start':
|
||||||
|
hookenv.open_port(port)
|
||||||
|
elif event_name == 'stop':
|
||||||
|
hookenv.close_port(port)
|
||||||
|
|
||||||
|
|
||||||
|
def service_stop(service_name):
|
||||||
|
"""
|
||||||
|
Wrapper around host.service_stop to prevent spurious "unknown service"
|
||||||
|
messages in the logs.
|
||||||
|
"""
|
||||||
|
if host.service_running(service_name):
|
||||||
|
host.service_stop(service_name)
|
||||||
|
|
||||||
|
|
||||||
|
def service_restart(service_name):
|
||||||
|
"""
|
||||||
|
Wrapper around host.service_restart to prevent spurious "unknown service"
|
||||||
|
messages in the logs.
|
||||||
|
"""
|
||||||
|
if host.service_available(service_name):
|
||||||
|
if host.service_running(service_name):
|
||||||
|
host.service_restart(service_name)
|
||||||
|
else:
|
||||||
|
host.service_start(service_name)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience aliases
|
||||||
|
open_ports = close_ports = manage_ports = PortManagerCallback()
|
|
@ -0,0 +1,267 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from charmhelpers.core import hookenv
|
||||||
|
from charmhelpers.core import templating
|
||||||
|
|
||||||
|
from charmhelpers.core.services.base import ManagerCallback
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['RelationContext', 'TemplateCallback',
|
||||||
|
'render_template', 'template']
|
||||||
|
|
||||||
|
|
||||||
|
class RelationContext(dict):
|
||||||
|
"""
|
||||||
|
Base class for a context generator that gets relation data from juju.
|
||||||
|
|
||||||
|
Subclasses must provide the attributes `name`, which is the name of the
|
||||||
|
interface of interest, `interface`, which is the type of the interface of
|
||||||
|
interest, and `required_keys`, which is the set of keys required for the
|
||||||
|
relation to be considered complete. The data for all interfaces matching
|
||||||
|
the `name` attribute that are complete will used to populate the dictionary
|
||||||
|
values (see `get_data`, below).
|
||||||
|
|
||||||
|
The generated context will be namespaced under the relation :attr:`name`,
|
||||||
|
to prevent potential naming conflicts.
|
||||||
|
|
||||||
|
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
||||||
|
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
||||||
|
"""
|
||||||
|
name = None
|
||||||
|
interface = None
|
||||||
|
|
||||||
|
def __init__(self, name=None, additional_required_keys=None):
|
||||||
|
if not hasattr(self, 'required_keys'):
|
||||||
|
self.required_keys = []
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
self.name = name
|
||||||
|
if additional_required_keys:
|
||||||
|
self.required_keys.extend(additional_required_keys)
|
||||||
|
self.get_data()
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""
|
||||||
|
Returns True if all of the required_keys are available.
|
||||||
|
"""
|
||||||
|
return self.is_ready()
|
||||||
|
|
||||||
|
__nonzero__ = __bool__
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return super(RelationContext, self).__repr__()
|
||||||
|
|
||||||
|
def is_ready(self):
|
||||||
|
"""
|
||||||
|
Returns True if all of the `required_keys` are available from any units.
|
||||||
|
"""
|
||||||
|
ready = len(self.get(self.name, [])) > 0
|
||||||
|
if not ready:
|
||||||
|
hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
|
||||||
|
return ready
|
||||||
|
|
||||||
|
def _is_ready(self, unit_data):
|
||||||
|
"""
|
||||||
|
Helper method that tests a set of relation data and returns True if
|
||||||
|
all of the `required_keys` are present.
|
||||||
|
"""
|
||||||
|
return set(unit_data.keys()).issuperset(set(self.required_keys))
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
"""
|
||||||
|
Retrieve the relation data for each unit involved in a relation and,
|
||||||
|
if complete, store it in a list under `self[self.name]`. This
|
||||||
|
is automatically called when the RelationContext is instantiated.
|
||||||
|
|
||||||
|
The units are sorted lexographically first by the service ID, then by
|
||||||
|
the unit ID. Thus, if an interface has two other services, 'db:1'
|
||||||
|
and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
|
||||||
|
and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
|
||||||
|
set of data, the relation data for the units will be stored in the
|
||||||
|
order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
|
||||||
|
|
||||||
|
If you only care about a single unit on the relation, you can just
|
||||||
|
access it as `{{ interface[0]['key'] }}`. However, if you can at all
|
||||||
|
support multiple units on a relation, you should iterate over the list,
|
||||||
|
like::
|
||||||
|
|
||||||
|
{% for unit in interface -%}
|
||||||
|
{{ unit['key'] }}{% if not loop.last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|
||||||
|
Note that since all sets of relation data from all related services and
|
||||||
|
units are in a single list, if you need to know which service or unit a
|
||||||
|
set of data came from, you'll need to extend this class to preserve
|
||||||
|
that information.
|
||||||
|
"""
|
||||||
|
if not hookenv.relation_ids(self.name):
|
||||||
|
return
|
||||||
|
|
||||||
|
ns = self.setdefault(self.name, [])
|
||||||
|
for rid in sorted(hookenv.relation_ids(self.name)):
|
||||||
|
for unit in sorted(hookenv.related_units(rid)):
|
||||||
|
reldata = hookenv.relation_get(rid=rid, unit=unit)
|
||||||
|
if self._is_ready(reldata):
|
||||||
|
ns.append(reldata)
|
||||||
|
|
||||||
|
def provide_data(self):
|
||||||
|
"""
|
||||||
|
Return data to be relation_set for this interface.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class MysqlRelation(RelationContext):
|
||||||
|
"""
|
||||||
|
Relation context for the `mysql` interface.
|
||||||
|
|
||||||
|
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
||||||
|
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
||||||
|
"""
|
||||||
|
name = 'db'
|
||||||
|
interface = 'mysql'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.required_keys = ['host', 'user', 'password', 'database']
|
||||||
|
RelationContext.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpRelation(RelationContext):
|
||||||
|
"""
|
||||||
|
Relation context for the `http` interface.
|
||||||
|
|
||||||
|
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
||||||
|
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
||||||
|
"""
|
||||||
|
name = 'website'
|
||||||
|
interface = 'http'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.required_keys = ['host', 'port']
|
||||||
|
RelationContext.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def provide_data(self):
|
||||||
|
return {
|
||||||
|
'host': hookenv.unit_get('private-address'),
|
||||||
|
'port': 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RequiredConfig(dict):
|
||||||
|
"""
|
||||||
|
Data context that loads config options with one or more mandatory options.
|
||||||
|
|
||||||
|
Once the required options have been changed from their default values, all
|
||||||
|
config options will be available, namespaced under `config` to prevent
|
||||||
|
potential naming conflicts (for example, between a config option and a
|
||||||
|
relation property).
|
||||||
|
|
||||||
|
:param list *args: List of options that must be changed from their default values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args):
|
||||||
|
self.required_options = args
|
||||||
|
self['config'] = hookenv.config()
|
||||||
|
with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
|
||||||
|
self.config = yaml.load(fp).get('options', {})
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
for option in self.required_options:
|
||||||
|
if option not in self['config']:
|
||||||
|
return False
|
||||||
|
current_value = self['config'][option]
|
||||||
|
default_value = self.config[option].get('default')
|
||||||
|
if current_value == default_value:
|
||||||
|
return False
|
||||||
|
if current_value in (None, '') and default_value in (None, ''):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __nonzero__(self):
|
||||||
|
return self.__bool__()
|
||||||
|
|
||||||
|
|
||||||
|
class StoredContext(dict):
|
||||||
|
"""
|
||||||
|
A data context that always returns the data that it was first created with.
|
||||||
|
|
||||||
|
This is useful to do a one-time generation of things like passwords, that
|
||||||
|
will thereafter use the same value that was originally generated, instead
|
||||||
|
of generating a new value each time it is run.
|
||||||
|
"""
|
||||||
|
def __init__(self, file_name, config_data):
|
||||||
|
"""
|
||||||
|
If the file exists, populate `self` with the data from the file.
|
||||||
|
Otherwise, populate with the given data and persist it to the file.
|
||||||
|
"""
|
||||||
|
if os.path.exists(file_name):
|
||||||
|
self.update(self.read_context(file_name))
|
||||||
|
else:
|
||||||
|
self.store_context(file_name, config_data)
|
||||||
|
self.update(config_data)
|
||||||
|
|
||||||
|
def store_context(self, file_name, config_data):
|
||||||
|
if not os.path.isabs(file_name):
|
||||||
|
file_name = os.path.join(hookenv.charm_dir(), file_name)
|
||||||
|
with open(file_name, 'w') as file_stream:
|
||||||
|
os.fchmod(file_stream.fileno(), 0o600)
|
||||||
|
yaml.dump(config_data, file_stream)
|
||||||
|
|
||||||
|
def read_context(self, file_name):
|
||||||
|
if not os.path.isabs(file_name):
|
||||||
|
file_name = os.path.join(hookenv.charm_dir(), file_name)
|
||||||
|
with open(file_name, 'r') as file_stream:
|
||||||
|
data = yaml.load(file_stream)
|
||||||
|
if not data:
|
||||||
|
raise OSError("%s is empty" % file_name)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCallback(ManagerCallback):
|
||||||
|
"""
|
||||||
|
Callback class that will render a Jinja2 template, for use as a ready
|
||||||
|
action.
|
||||||
|
|
||||||
|
:param str source: The template source file, relative to
|
||||||
|
`$CHARM_DIR/templates`
|
||||||
|
|
||||||
|
:param str target: The target to write the rendered template to
|
||||||
|
:param str owner: The owner of the rendered file
|
||||||
|
:param str group: The group of the rendered file
|
||||||
|
:param int perms: The permissions of the rendered file
|
||||||
|
"""
|
||||||
|
def __init__(self, source, target,
|
||||||
|
owner='root', group='root', perms=0o444):
|
||||||
|
self.source = source
|
||||||
|
self.target = target
|
||||||
|
self.owner = owner
|
||||||
|
self.group = group
|
||||||
|
self.perms = perms
|
||||||
|
|
||||||
|
def __call__(self, manager, service_name, event_name):
|
||||||
|
service = manager.get_service(service_name)
|
||||||
|
context = {}
|
||||||
|
for ctx in service.get('required_data', []):
|
||||||
|
context.update(ctx)
|
||||||
|
templating.render(self.source, self.target, context,
|
||||||
|
self.owner, self.group, self.perms)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience aliases for templates
|
||||||
|
render_template = template = TemplateCallback
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
|
||||||
|
def bool_from_string(value):
|
||||||
|
"""Interpret string value as boolean.
|
||||||
|
|
||||||
|
Returns True if value translates to True otherwise False.
|
||||||
|
"""
|
||||||
|
if isinstance(value, six.string_types):
|
||||||
|
value = six.text_type(value)
|
||||||
|
else:
|
||||||
|
msg = "Unable to interpret non-string value '%s' as boolean" % (value)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
value = value.strip().lower()
|
||||||
|
|
||||||
|
if value in ['y', 'yes', 'true', 't', 'on']:
|
||||||
|
return True
|
||||||
|
elif value in ['n', 'no', 'false', 'f', 'off']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
msg = "Unable to interpret string value '%s' as boolean" % (value)
|
||||||
|
raise ValueError(msg)
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from subprocess import check_call
|
||||||
|
|
||||||
|
from charmhelpers.core.hookenv import (
|
||||||
|
log,
|
||||||
|
DEBUG,
|
||||||
|
ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
|
||||||
|
|
||||||
|
|
||||||
|
def create(sysctl_dict, sysctl_file):
|
||||||
|
"""Creates a sysctl.conf file from a YAML associative array
|
||||||
|
|
||||||
|
:param sysctl_dict: a YAML-formatted string of sysctl options eg "{ 'kernel.max_pid': 1337 }"
|
||||||
|
:type sysctl_dict: str
|
||||||
|
:param sysctl_file: path to the sysctl file to be saved
|
||||||
|
:type sysctl_file: str or unicode
|
||||||
|
:returns: None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
|
||||||
|
level=ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(sysctl_file, "w") as fd:
|
||||||
|
for key, value in sysctl_dict_parsed.items():
|
||||||
|
fd.write("{}={}\n".format(key, value))
|
||||||
|
|
||||||
|
log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
|
||||||
|
level=DEBUG)
|
||||||
|
|
||||||
|
check_call(["sysctl", "-p", sysctl_file])
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from charmhelpers.core import host
|
||||||
|
from charmhelpers.core import hookenv
|
||||||
|
|
||||||
|
|
||||||
|
def render(source, target, context, owner='root', group='root',
|
||||||
|
perms=0o444, templates_dir=None, encoding='UTF-8'):
|
||||||
|
"""
|
||||||
|
Render a template.
|
||||||
|
|
||||||
|
The `source` path, if not absolute, is relative to the `templates_dir`.
|
||||||
|
|
||||||
|
The `target` path should be absolute.
|
||||||
|
|
||||||
|
The context should be a dict containing the values to be replaced in the
|
||||||
|
template.
|
||||||
|
|
||||||
|
The `owner`, `group`, and `perms` options will be passed to `write_file`.
|
||||||
|
|
||||||
|
If omitted, `templates_dir` defaults to the `templates` folder in the charm.
|
||||||
|
|
||||||
|
Note: Using this requires python-jinja2; if it is not installed, calling
|
||||||
|
this will attempt to use charmhelpers.fetch.apt_install to install it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from jinja2 import FileSystemLoader, Environment, exceptions
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from charmhelpers.fetch import apt_install
|
||||||
|
except ImportError:
|
||||||
|
hookenv.log('Could not import jinja2, and could not import '
|
||||||
|
'charmhelpers.fetch to install it',
|
||||||
|
level=hookenv.ERROR)
|
||||||
|
raise
|
||||||
|
apt_install('python-jinja2', fatal=True)
|
||||||
|
from jinja2 import FileSystemLoader, Environment, exceptions
|
||||||
|
|
||||||
|
if templates_dir is None:
|
||||||
|
templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
|
||||||
|
loader = Environment(loader=FileSystemLoader(templates_dir))
|
||||||
|
try:
|
||||||
|
source = source
|
||||||
|
template = loader.get_template(source)
|
||||||
|
except exceptions.TemplateNotFound as e:
|
||||||
|
hookenv.log('Could not load template %s from %s.' %
|
||||||
|
(source, templates_dir),
|
||||||
|
level=hookenv.ERROR)
|
||||||
|
raise e
|
||||||
|
content = template.render(context)
|
||||||
|
host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
|
||||||
|
host.write_file(target, content.encode(encoding), owner, group, perms)
|
|
@ -0,0 +1,477 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright 2014-2015 Canonical Limited.
|
||||||
|
#
|
||||||
|
# This file is part of charm-helpers.
|
||||||
|
#
|
||||||
|
# charm-helpers is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License version 3 as
|
||||||
|
# published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# charm-helpers is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Kapil Thangavelu <kapil.foss@gmail.com>
|
||||||
|
#
|
||||||
|
"""
|
||||||
|
Intro
|
||||||
|
-----
|
||||||
|
|
||||||
|
A simple way to store state in units. This provides a key value
|
||||||
|
storage with support for versioned, transactional operation,
|
||||||
|
and can calculate deltas from previous values to simplify unit logic
|
||||||
|
when processing changes.
|
||||||
|
|
||||||
|
|
||||||
|
Hook Integration
|
||||||
|
----------------
|
||||||
|
|
||||||
|
There are several extant frameworks for hook execution, including
|
||||||
|
|
||||||
|
- charmhelpers.core.hookenv.Hooks
|
||||||
|
- charmhelpers.core.services.ServiceManager
|
||||||
|
|
||||||
|
The storage classes are framework agnostic, one simple integration is
|
||||||
|
via the HookData contextmanager. It will record the current hook
|
||||||
|
execution environment (including relation data, config data, etc.),
|
||||||
|
setup a transaction and allow easy access to the changes from
|
||||||
|
previously seen values. One consequence of the integration is the
|
||||||
|
reservation of particular keys ('rels', 'unit', 'env', 'config',
|
||||||
|
'charm_revisions') for their respective values.
|
||||||
|
|
||||||
|
Here's a fully worked integration example using hookenv.Hooks::
|
||||||
|
|
||||||
|
from charmhelper.core import hookenv, unitdata
|
||||||
|
|
||||||
|
hook_data = unitdata.HookData()
|
||||||
|
db = unitdata.kv()
|
||||||
|
hooks = hookenv.Hooks()
|
||||||
|
|
||||||
|
@hooks.hook
|
||||||
|
def config_changed():
|
||||||
|
# Print all changes to configuration from previously seen
|
||||||
|
# values.
|
||||||
|
for changed, (prev, cur) in hook_data.conf.items():
|
||||||
|
print('config changed', changed,
|
||||||
|
'previous value', prev,
|
||||||
|
'current value', cur)
|
||||||
|
|
||||||
|
# Get some unit specific bookeeping
|
||||||
|
if not db.get('pkg_key'):
|
||||||
|
key = urllib.urlopen('https://example.com/pkg_key').read()
|
||||||
|
db.set('pkg_key', key)
|
||||||
|
|
||||||
|
# Directly access all charm config as a mapping.
|
||||||
|
conf = db.getrange('config', True)
|
||||||
|
|
||||||
|
# Directly access all relation data as a mapping
|
||||||
|
rels = db.getrange('rels', True)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
with hook_data():
|
||||||
|
hook.execute()
|
||||||
|
|
||||||
|
|
||||||
|
A more basic integration is via the hook_scope context manager which simply
|
||||||
|
manages transaction scope (and records hook name, and timestamp)::
|
||||||
|
|
||||||
|
>>> from unitdata import kv
|
||||||
|
>>> db = kv()
|
||||||
|
>>> with db.hook_scope('install'):
|
||||||
|
... # do work, in transactional scope.
|
||||||
|
... db.set('x', 1)
|
||||||
|
>>> db.get('x')
|
||||||
|
1
|
||||||
|
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
|
||||||
|
Values are automatically json de/serialized to preserve basic typing
|
||||||
|
and complex data struct capabilities (dicts, lists, ints, booleans, etc).
|
||||||
|
|
||||||
|
Individual values can be manipulated via get/set::
|
||||||
|
|
||||||
|
>>> kv.set('y', True)
|
||||||
|
>>> kv.get('y')
|
||||||
|
True
|
||||||
|
|
||||||
|
# We can set complex values (dicts, lists) as a single key.
|
||||||
|
>>> kv.set('config', {'a': 1, 'b': True'})
|
||||||
|
|
||||||
|
# Also supports returning dictionaries as a record which
|
||||||
|
# provides attribute access.
|
||||||
|
>>> config = kv.get('config', record=True)
|
||||||
|
>>> config.b
|
||||||
|
True
|
||||||
|
|
||||||
|
|
||||||
|
Groups of keys can be manipulated with update/getrange::
|
||||||
|
|
||||||
|
>>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
|
||||||
|
>>> kv.getrange('gui.', strip=True)
|
||||||
|
{'z': 1, 'y': 2}
|
||||||
|
|
||||||
|
When updating values, its very helpful to understand which values
|
||||||
|
have actually changed and how have they changed. The storage
|
||||||
|
provides a delta method to provide for this::
|
||||||
|
|
||||||
|
>>> data = {'debug': True, 'option': 2}
|
||||||
|
>>> delta = kv.delta(data, 'config.')
|
||||||
|
>>> delta.debug.previous
|
||||||
|
None
|
||||||
|
>>> delta.debug.current
|
||||||
|
True
|
||||||
|
>>> delta
|
||||||
|
{'debug': (None, True), 'option': (None, 2)}
|
||||||
|
|
||||||
|
Note the delta method does not persist the actual change, it needs to
|
||||||
|
be explicitly saved via 'update' method::
|
||||||
|
|
||||||
|
>>> kv.update(data, 'config.')
|
||||||
|
|
||||||
|
Values modified in the context of a hook scope retain historical values
|
||||||
|
associated to the hookname.
|
||||||
|
|
||||||
|
>>> with db.hook_scope('config-changed'):
|
||||||
|
... db.set('x', 42)
|
||||||
|
>>> db.gethistory('x')
|
||||||
|
[(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
|
||||||
|
(2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import contextlib
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pprint
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
|
||||||
|
|
||||||
|
|
||||||
|
class Storage(object):
|
||||||
|
"""Simple key value database for local unit state within charms.
|
||||||
|
|
||||||
|
Modifications are automatically committed at hook exit. That's
|
||||||
|
currently regardless of exit code.
|
||||||
|
|
||||||
|
To support dicts, lists, integer, floats, and booleans values
|
||||||
|
are automatically json encoded/decoded.
|
||||||
|
"""
|
||||||
|
def __init__(self, path=None):
|
||||||
|
self.db_path = path
|
||||||
|
if path is None:
|
||||||
|
self.db_path = os.path.join(
|
||||||
|
os.environ.get('CHARM_DIR', ''), '.unit-state.db')
|
||||||
|
self.conn = sqlite3.connect('%s' % self.db_path)
|
||||||
|
self.cursor = self.conn.cursor()
|
||||||
|
self.revision = None
|
||||||
|
self._closed = False
|
||||||
|
self._init()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self.flush(False)
|
||||||
|
self.cursor.close()
|
||||||
|
self.conn.close()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def _scoped_query(self, stmt, params=None):
|
||||||
|
if params is None:
|
||||||
|
params = []
|
||||||
|
return stmt, params
|
||||||
|
|
||||||
|
def get(self, key, default=None, record=False):
|
||||||
|
self.cursor.execute(
|
||||||
|
*self._scoped_query(
|
||||||
|
'select data from kv where key=?', [key]))
|
||||||
|
result = self.cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
return default
|
||||||
|
if record:
|
||||||
|
return Record(json.loads(result[0]))
|
||||||
|
return json.loads(result[0])
|
||||||
|
|
||||||
|
def getrange(self, key_prefix, strip=False):
|
||||||
|
stmt = "select key, data from kv where key like '%s%%'" % key_prefix
|
||||||
|
self.cursor.execute(*self._scoped_query(stmt))
|
||||||
|
result = self.cursor.fetchall()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
if not strip:
|
||||||
|
key_prefix = ''
|
||||||
|
return dict([
|
||||||
|
(k[len(key_prefix):], json.loads(v)) for k, v in result])
|
||||||
|
|
||||||
|
def update(self, mapping, prefix=""):
|
||||||
|
for k, v in mapping.items():
|
||||||
|
self.set("%s%s" % (prefix, k), v)
|
||||||
|
|
||||||
|
def unset(self, key):
|
||||||
|
self.cursor.execute('delete from kv where key=?', [key])
|
||||||
|
if self.revision and self.cursor.rowcount:
|
||||||
|
self.cursor.execute(
|
||||||
|
'insert into kv_revisions values (?, ?, ?)',
|
||||||
|
[key, self.revision, json.dumps('DELETED')])
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
serialized = json.dumps(value)
|
||||||
|
|
||||||
|
self.cursor.execute(
|
||||||
|
'select data from kv where key=?', [key])
|
||||||
|
exists = self.cursor.fetchone()
|
||||||
|
|
||||||
|
# Skip mutations to the same value
|
||||||
|
if exists:
|
||||||
|
if exists[0] == serialized:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
self.cursor.execute(
|
||||||
|
'insert into kv (key, data) values (?, ?)',
|
||||||
|
(key, serialized))
|
||||||
|
else:
|
||||||
|
self.cursor.execute('''
|
||||||
|
update kv
|
||||||
|
set data = ?
|
||||||
|
where key = ?''', [serialized, key])
|
||||||
|
|
||||||
|
# Save
|
||||||
|
if not self.revision:
|
||||||
|
return value
|
||||||
|
|
||||||
|
self.cursor.execute(
|
||||||
|
'select 1 from kv_revisions where key=? and revision=?',
|
||||||
|
[key, self.revision])
|
||||||
|
exists = self.cursor.fetchone()
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
self.cursor.execute(
|
||||||
|
'''insert into kv_revisions (
|
||||||
|
revision, key, data) values (?, ?, ?)''',
|
||||||
|
(self.revision, key, serialized))
|
||||||
|
else:
|
||||||
|
self.cursor.execute(
|
||||||
|
'''
|
||||||
|
update kv_revisions
|
||||||
|
set data = ?
|
||||||
|
where key = ?
|
||||||
|
and revision = ?''',
|
||||||
|
[serialized, key, self.revision])
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def delta(self, mapping, prefix):
|
||||||
|
"""
|
||||||
|
return a delta containing values that have changed.
|
||||||
|
"""
|
||||||
|
previous = self.getrange(prefix, strip=True)
|
||||||
|
if not previous:
|
||||||
|
pk = set()
|
||||||
|
else:
|
||||||
|
pk = set(previous.keys())
|
||||||
|
ck = set(mapping.keys())
|
||||||
|
delta = DeltaSet()
|
||||||
|
|
||||||
|
# added
|
||||||
|
for k in ck.difference(pk):
|
||||||
|
delta[k] = Delta(None, mapping[k])
|
||||||
|
|
||||||
|
# removed
|
||||||
|
for k in pk.difference(ck):
|
||||||
|
delta[k] = Delta(previous[k], None)
|
||||||
|
|
||||||
|
# changed
|
||||||
|
for k in pk.intersection(ck):
|
||||||
|
c = mapping[k]
|
||||||
|
p = previous[k]
|
||||||
|
if c != p:
|
||||||
|
delta[k] = Delta(p, c)
|
||||||
|
|
||||||
|
return delta
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def hook_scope(self, name=""):
|
||||||
|
"""Scope all future interactions to the current hook execution
|
||||||
|
revision."""
|
||||||
|
assert not self.revision
|
||||||
|
self.cursor.execute(
|
||||||
|
'insert into hooks (hook, date) values (?, ?)',
|
||||||
|
(name or sys.argv[0],
|
||||||
|
datetime.datetime.utcnow().isoformat()))
|
||||||
|
self.revision = self.cursor.lastrowid
|
||||||
|
try:
|
||||||
|
yield self.revision
|
||||||
|
self.revision = None
|
||||||
|
except:
|
||||||
|
self.flush(False)
|
||||||
|
self.revision = None
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.flush()
|
||||||
|
|
||||||
|
def flush(self, save=True):
|
||||||
|
if save:
|
||||||
|
self.conn.commit()
|
||||||
|
elif self._closed:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.conn.rollback()
|
||||||
|
|
||||||
|
def _init(self):
|
||||||
|
self.cursor.execute('''
|
||||||
|
create table if not exists kv (
|
||||||
|
key text,
|
||||||
|
data text,
|
||||||
|
primary key (key)
|
||||||
|
)''')
|
||||||
|
self.cursor.execute('''
|
||||||
|
create table if not exists kv_revisions (
|
||||||
|
key text,
|
||||||
|
revision integer,
|
||||||
|
data text,
|
||||||
|
primary key (key, revision)
|
||||||
|
)''')
|
||||||
|
self.cursor.execute('''
|
||||||
|
create table if not exists hooks (
|
||||||
|
version integer primary key autoincrement,
|
||||||
|
hook text,
|
||||||
|
date text
|
||||||
|
)''')
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def gethistory(self, key, deserialize=False):
|
||||||
|
self.cursor.execute(
|
||||||
|
'''
|
||||||
|
select kv.revision, kv.key, kv.data, h.hook, h.date
|
||||||
|
from kv_revisions kv,
|
||||||
|
hooks h
|
||||||
|
where kv.key=?
|
||||||
|
and kv.revision = h.version
|
||||||
|
''', [key])
|
||||||
|
if deserialize is False:
|
||||||
|
return self.cursor.fetchall()
|
||||||
|
return map(_parse_history, self.cursor.fetchall())
|
||||||
|
|
||||||
|
def debug(self, fh=sys.stderr):
|
||||||
|
self.cursor.execute('select * from kv')
|
||||||
|
pprint.pprint(self.cursor.fetchall(), stream=fh)
|
||||||
|
self.cursor.execute('select * from kv_revisions')
|
||||||
|
pprint.pprint(self.cursor.fetchall(), stream=fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_history(d):
|
||||||
|
return (d[0], d[1], json.loads(d[2]), d[3],
|
||||||
|
datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
|
||||||
|
|
||||||
|
|
||||||
|
class HookData(object):
|
||||||
|
"""Simple integration for existing hook exec frameworks.
|
||||||
|
|
||||||
|
Records all unit information, and stores deltas for processing
|
||||||
|
by the hook.
|
||||||
|
|
||||||
|
Sample::
|
||||||
|
|
||||||
|
from charmhelper.core import hookenv, unitdata
|
||||||
|
|
||||||
|
changes = unitdata.HookData()
|
||||||
|
db = unitdata.kv()
|
||||||
|
hooks = hookenv.Hooks()
|
||||||
|
|
||||||
|
@hooks.hook
|
||||||
|
def config_changed():
|
||||||
|
# View all changes to configuration
|
||||||
|
for changed, (prev, cur) in changes.conf.items():
|
||||||
|
print('config changed', changed,
|
||||||
|
'previous value', prev,
|
||||||
|
'current value', cur)
|
||||||
|
|
||||||
|
# Get some unit specific bookeeping
|
||||||
|
if not db.get('pkg_key'):
|
||||||
|
key = urllib.urlopen('https://example.com/pkg_key').read()
|
||||||
|
db.set('pkg_key', key)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
with changes():
|
||||||
|
hook.execute()
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.kv = kv()
|
||||||
|
self.conf = None
|
||||||
|
self.rels = None
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def __call__(self):
|
||||||
|
from charmhelpers.core import hookenv
|
||||||
|
hook_name = hookenv.hook_name()
|
||||||
|
|
||||||
|
with self.kv.hook_scope(hook_name):
|
||||||
|
self._record_charm_version(hookenv.charm_dir())
|
||||||
|
delta_config, delta_relation = self._record_hook(hookenv)
|
||||||
|
yield self.kv, delta_config, delta_relation
|
||||||
|
|
||||||
|
def _record_charm_version(self, charm_dir):
|
||||||
|
# Record revisions.. charm revisions are meaningless
|
||||||
|
# to charm authors as they don't control the revision.
|
||||||
|
# so logic dependnent on revision is not particularly
|
||||||
|
# useful, however it is useful for debugging analysis.
|
||||||
|
charm_rev = open(
|
||||||
|
os.path.join(charm_dir, 'revision')).read().strip()
|
||||||
|
charm_rev = charm_rev or '0'
|
||||||
|
revs = self.kv.get('charm_revisions', [])
|
||||||
|
if charm_rev not in revs:
|
||||||
|
revs.append(charm_rev.strip() or '0')
|
||||||
|
self.kv.set('charm_revisions', revs)
|
||||||
|
|
||||||
|
def _record_hook(self, hookenv):
|
||||||
|
data = hookenv.execution_environment()
|
||||||
|
self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
|
||||||
|
self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
|
||||||
|
self.kv.set('env', dict(data['env']))
|
||||||
|
self.kv.set('unit', data['unit'])
|
||||||
|
self.kv.set('relid', data.get('relid'))
|
||||||
|
return conf_delta, rels_delta
|
||||||
|
|
||||||
|
|
||||||
|
class Record(dict):
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __getattr__(self, k):
|
||||||
|
if k in self:
|
||||||
|
return self[k]
|
||||||
|
raise AttributeError(k)
|
||||||
|
|
||||||
|
|
||||||
|
class DeltaSet(Record):
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
Delta = collections.namedtuple('Delta', ['previous', 'current'])
|
||||||
|
|
||||||
|
|
||||||
|
_KV = None
|
||||||
|
|
||||||
|
|
||||||
|
def kv():
|
||||||
|
global _KV
|
||||||
|
if _KV is None:
|
||||||
|
_KV = Storage()
|
||||||
|
return _KV
|
Loading…
Reference in New Issue