Enable Database TLS

Bring the charms.openstack Database handling into line with classic
charms and charm-helpers.

* Write out the SSL key and certs from relation data
* Point sqlalchamy at the files

Change-Id: I78218aa972ead49f144bb19a988bd6f0bbf4a539
This commit is contained in:
David Ames 2020-07-10 15:27:17 -07:00
parent d0431be73d
commit 2ed7b212ef
2 changed files with 159 additions and 30 deletions

View File

@ -15,8 +15,10 @@
"""Adapter classes and utilities for use with Reactive interfaces"""
from __future__ import absolute_import
import base64
import collections
import itertools
import os
import re
import weakref
@ -31,6 +33,7 @@ import charmhelpers.core.host as ch_host
import charms_openstack.ip as os_ip
ADDRESS_TYPES = sorted(os_ip.ADDRESS_MAP.keys(), reverse=True)
CA_CERTS_DIR = "/usr/local/share/ca-certificates"
# handle declarative adapter properties using a decorator and simple functions
@ -94,7 +97,8 @@ class OpenStackRelationAdapter(object):
The generic type of the interface the adapter is wrapping.
"""
def __init__(self, relation=None, accessors=None, relation_name=None):
def __init__(self, relation=None, accessors=None, relation_name=None,
charm_instance=None):
"""Class will usually be initialised using the 'relation' option to
pass in an instance of a interface class. If there is no relation
class yet available then 'relation_name' can be used instead.
@ -102,6 +106,7 @@ class OpenStackRelationAdapter(object):
:param relation: Instance of an interface class
:param accessors: List of accessible interfaces properties
:param relation_name: String name of relation
:param charm_instance: Instantiation of charm class
"""
self.relation = relation
if relation and relation_name:
@ -111,6 +116,7 @@ class OpenStackRelationAdapter(object):
self._setup_properties()
else:
self._relation_name = relation_name
self.charm_instance = charm_instance
@property
def relation_name(self):
@ -443,13 +449,14 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
interface_type = "database"
def __init__(self, relation):
def __init__(self, relation, ssl_dir=CA_CERTS_DIR, charm_instance=None):
# Note: These accessors need closer inspection and potentially need
# to be removed. The actual interface implements them as methods with
# parameters. See bug: https://bugs.launchpad.net/bugs/1848216.
add_accessors = ['password', 'username', 'database']
super(DatabaseRelationAdapter, self).__init__(relation, add_accessors)
self.config = hookenv.config()
super(DatabaseRelationAdapter, self).__init__(
relation, add_accessors, charm_instance=charm_instance)
self.set_ssl_dir(ssl_dir)
@property
def host(self):
@ -469,18 +476,104 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
def type(self):
return 'mysql'
def set_ssl_dir(self, ssl_dir):
"""
Set the SSL dir to a non-default location. It may be desireble to
continue using:
/etc/apache2/ssl/<charm name>
:param ssl_dir: Directory to write out certificates/keys
:type ssl_dir: string
:returns: None
"rtype: None
"""
self.ssl_dir = ssl_dir
@property
def database_ssl_ca(self):
"""
Database SSL Certificate Authority
Write out the CA to disk if it is on the relation.
:returns: Path to SSL Certificate Authority
:rtype: Union[string, None]
"""
if self.relation.ssl_ca():
ca_path = os.path.join(self.ssl_dir, 'db-client.ca')
# Note: self.charm_instance.group is used to set permissions.
# If for some reason the charm has not set the group it defaults
# to 'root' group ownership which may not be correct.
ch_host.write_file(
path=ca_path,
content=base64.b64decode(self.relation.ssl_ca()),
group=self.charm_instance.group,
perms=0o644)
return ca_path
@property
def database_ssl_cert(self):
"""
Database SSL Certificate
Write out the certificate to disk if it is on the relation.
:returns: Path to SSL Certificate
:rtype: Union[string, None]
"""
if self.relation.ssl_cert():
cert_path = os.path.join(self.ssl_dir, 'db-client.cert')
# Note: self.charm_instance.group is used to set permissions.
# If for some reason the charm has not set the group it defaults
# to 'root' group ownership which may not be correct.
ch_host.write_file(
path=cert_path,
content=base64.b64decode(self.relation.ssl_cert()),
group=self.charm_instance.group,
perms=0o644)
return cert_path
@property
def database_ssl_key(self):
"""
Database SSL Key
Write out the key to disk if it is on the relation.
:returns: Path to SSL Key
:rtype: Union[string, None]
"""
if self.relation.ssl_key():
key_path = os.path.join(self.ssl_dir, 'db-client.key')
# Note: self.charm_instance.group is used to set permissions.
# If for some reason the charm has not set the group it defaults
# to 'root' group ownership which may not be correct.
ch_host.write_file(
path=key_path,
content=base64.b64decode(self.relation.ssl_key()),
group=self.charm_instance.group,
perms=0o640)
return key_path
def get_password(self, prefix=None):
if prefix:
return self.relation.password(prefix=prefix)
return self.password
def get_uri(self, prefix=None):
@property
def driver(self):
driver = 'mysql'
release = ch_utils.get_os_codename_install_source(
self.config['openstack-origin'])
self.charm_instance.options.openstack_origin)
if (ch_utils.OPENSTACK_RELEASES.index(release) >=
ch_utils.OPENSTACK_RELEASES.index('stein')):
driver = 'mysql+pymysql'
return driver
def get_uri(self, prefix=None):
if prefix:
username = self.relation.username(prefix=prefix)
password = self.relation.password(prefix=prefix)
@ -491,7 +584,7 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
database = self.database
if self.port:
uri = '{}://{}:{}@{}:{}/{}'.format(
driver,
self.driver,
username,
password,
self.host,
@ -501,21 +594,20 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
else:
# defensive code if port is not passed
uri = '{}://{}:{}@{}/{}'.format(
driver,
self.driver,
username,
password,
self.host,
database,
)
try:
if self.ssl_ca:
uri = '{}?ssl_ca={}'.format(uri, self.ssl_ca)
if self.ssl_cert:
uri = ('{}&ssl_cert={}&ssl_key={}'
.format(uri, self.ssl_cert, self.ssl_key))
except AttributeError:
# ignore ssl_ca or ssl_cert if not available
pass
if self.database_ssl_ca:
uri = '{}?ssl_ca={}'.format(uri, self.database_ssl_ca)
if self.database_ssl_cert:
uri = ('{}&ssl_cert={}&ssl_key={}'
.format(
uri,
self.database_ssl_cert,
self.database_ssl_key))
return uri
@property
@ -1203,9 +1295,13 @@ class OpenStackRelationAdapters(object):
except AttributeError:
relation_name = relation.relation_name.replace('-', '_')
try:
adapter = self._adapters[relation_name](relation)
cls = self._adapters[relation_name]
except KeyError:
adapter = OpenStackRelationAdapter(relation)
cls = OpenStackRelationAdapter
try:
adapter = cls(relation, charm_instance=self.charm_instance)
except TypeError:
adapter = cls(relation)
return relation_name, adapter

View File

@ -385,12 +385,38 @@ class FakeDatabaseRelation():
def database(self, prefix=''):
return 'database1{}'.format(prefix)
def ssl_ca(self):
return None
def ssl_cert(self):
return None
def ssl_key(self):
return None
class FakeCharmInstance():
def __init__(self):
self.group = "group"
self.options = mock.MagicMock()
self.options.openstack_origin = "cloud:bionic-rocky"
class SSLDatabaseRelationAdapter(adapters.DatabaseRelationAdapter):
ssl_ca = 'my-ca'
ssl_cert = 'my-cert'
ssl_key = 'my-key'
def __init__(self, relation, ssl_dir=None, charm_instance=None):
relation.ssl_ca = lambda: (
adapters.base64.b64encode('my-ca'.encode('UTF-8')))
relation.ssl_cert = lambda: (
adapters.base64.b64encode('my-cert'.encode('UTF-8')))
relation.ssl_key = lambda: (
adapters.base64.b64encode('my-key'.encode('UTF-8')))
if ssl_dir:
super().__init__(
relation, ssl_dir=ssl_dir, charm_instance=charm_instance)
else:
super().__init__(relation, charm_instance=charm_instance)
class TestDatabaseRelationAdapter(unittest.TestCase):
@ -400,7 +426,8 @@ class TestDatabaseRelationAdapter(unittest.TestCase):
'get_os_codename_install_source',
return_value='rocky'):
fake = FakeDatabaseRelation()
db = adapters.DatabaseRelationAdapter(fake)
db = adapters.DatabaseRelationAdapter(
fake, charm_instance=FakeCharmInstance())
self.assertEqual(db.host, 'host1')
self.assertEqual(db.port, 3306)
self.assertEqual(db.type, 'mysql')
@ -417,17 +444,21 @@ class TestDatabaseRelationAdapter(unittest.TestCase):
db.get_password('x'),
'password1x')
# test the ssl feature of the base class
db = SSLDatabaseRelationAdapter(fake)
db = SSLDatabaseRelationAdapter(
fake, charm_instance=FakeCharmInstance())
self.assertEqual(
db.uri,
'mysql://username1:password1@host1:3306/database1'
'?ssl_ca=my-ca'
'&ssl_cert=my-cert&ssl_key=my-key')
'?ssl_ca=/usr/local/share/ca-certificates/db-client.ca'
'&ssl_cert=/usr/local/share/ca-certificates/db-client.cert'
'&ssl_key=/usr/local/share/ca-certificates/db-client.key')
with mock.patch.object(adapters.ch_utils,
'get_os_codename_install_source',
return_value='stein'):
ssl_dir = '/ssl/path'
fake = FakeDatabaseRelation()
db = adapters.DatabaseRelationAdapter(fake)
db = adapters.DatabaseRelationAdapter(
fake, charm_instance=FakeCharmInstance())
self.assertEqual(
db.uri,
'mysql+pymysql://username1:password1@host1:3306/database1')
@ -438,12 +469,14 @@ class TestDatabaseRelationAdapter(unittest.TestCase):
db.get_password('x'),
'password1x')
# test the ssl feature of the base class
db = SSLDatabaseRelationAdapter(fake)
db = SSLDatabaseRelationAdapter(
fake, ssl_dir=ssl_dir, charm_instance=FakeCharmInstance())
self.assertEqual(
db.uri,
'mysql+pymysql://username1:password1@host1:3306/database1'
'?ssl_ca=my-ca'
'&ssl_cert=my-cert&ssl_key=my-key')
'?ssl_ca={ssl_dir}/db-client.ca'
'&ssl_cert={ssl_dir}/db-client.cert'
'&ssl_key={ssl_dir}/db-client.key'.format(ssl_dir=ssl_dir))
class TestConfigurationAdapter(unittest.TestCase):