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""" """Adapter classes and utilities for use with Reactive interfaces"""
from __future__ import absolute_import from __future__ import absolute_import
import base64
import collections import collections
import itertools import itertools
import os
import re import re
import weakref import weakref
@ -31,6 +33,7 @@ import charmhelpers.core.host as ch_host
import charms_openstack.ip as os_ip import charms_openstack.ip as os_ip
ADDRESS_TYPES = sorted(os_ip.ADDRESS_MAP.keys(), reverse=True) 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 # 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. 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 """Class will usually be initialised using the 'relation' option to
pass in an instance of a interface class. If there is no relation pass in an instance of a interface class. If there is no relation
class yet available then 'relation_name' can be used instead. 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 relation: Instance of an interface class
:param accessors: List of accessible interfaces properties :param accessors: List of accessible interfaces properties
:param relation_name: String name of relation :param relation_name: String name of relation
:param charm_instance: Instantiation of charm class
""" """
self.relation = relation self.relation = relation
if relation and relation_name: if relation and relation_name:
@ -111,6 +116,7 @@ class OpenStackRelationAdapter(object):
self._setup_properties() self._setup_properties()
else: else:
self._relation_name = relation_name self._relation_name = relation_name
self.charm_instance = charm_instance
@property @property
def relation_name(self): def relation_name(self):
@ -443,13 +449,14 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
interface_type = "database" 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 # Note: These accessors need closer inspection and potentially need
# to be removed. The actual interface implements them as methods with # to be removed. The actual interface implements them as methods with
# parameters. See bug: https://bugs.launchpad.net/bugs/1848216. # parameters. See bug: https://bugs.launchpad.net/bugs/1848216.
add_accessors = ['password', 'username', 'database'] add_accessors = ['password', 'username', 'database']
super(DatabaseRelationAdapter, self).__init__(relation, add_accessors) super(DatabaseRelationAdapter, self).__init__(
self.config = hookenv.config() relation, add_accessors, charm_instance=charm_instance)
self.set_ssl_dir(ssl_dir)
@property @property
def host(self): def host(self):
@ -469,18 +476,104 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
def type(self): def type(self):
return 'mysql' 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): def get_password(self, prefix=None):
if prefix: if prefix:
return self.relation.password(prefix=prefix) return self.relation.password(prefix=prefix)
return self.password return self.password
def get_uri(self, prefix=None): @property
def driver(self):
driver = 'mysql' driver = 'mysql'
release = ch_utils.get_os_codename_install_source( 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) >= if (ch_utils.OPENSTACK_RELEASES.index(release) >=
ch_utils.OPENSTACK_RELEASES.index('stein')): ch_utils.OPENSTACK_RELEASES.index('stein')):
driver = 'mysql+pymysql' driver = 'mysql+pymysql'
return driver
def get_uri(self, prefix=None):
if prefix: if prefix:
username = self.relation.username(prefix=prefix) username = self.relation.username(prefix=prefix)
password = self.relation.password(prefix=prefix) password = self.relation.password(prefix=prefix)
@ -491,7 +584,7 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
database = self.database database = self.database
if self.port: if self.port:
uri = '{}://{}:{}@{}:{}/{}'.format( uri = '{}://{}:{}@{}:{}/{}'.format(
driver, self.driver,
username, username,
password, password,
self.host, self.host,
@ -501,21 +594,20 @@ class DatabaseRelationAdapter(OpenStackRelationAdapter):
else: else:
# defensive code if port is not passed # defensive code if port is not passed
uri = '{}://{}:{}@{}/{}'.format( uri = '{}://{}:{}@{}/{}'.format(
driver, self.driver,
username, username,
password, password,
self.host, self.host,
database, database,
) )
try: if self.database_ssl_ca:
if self.ssl_ca: uri = '{}?ssl_ca={}'.format(uri, self.database_ssl_ca)
uri = '{}?ssl_ca={}'.format(uri, self.ssl_ca) if self.database_ssl_cert:
if self.ssl_cert: uri = ('{}&ssl_cert={}&ssl_key={}'
uri = ('{}&ssl_cert={}&ssl_key={}' .format(
.format(uri, self.ssl_cert, self.ssl_key)) uri,
except AttributeError: self.database_ssl_cert,
# ignore ssl_ca or ssl_cert if not available self.database_ssl_key))
pass
return uri return uri
@property @property
@ -1203,9 +1295,13 @@ class OpenStackRelationAdapters(object):
except AttributeError: except AttributeError:
relation_name = relation.relation_name.replace('-', '_') relation_name = relation.relation_name.replace('-', '_')
try: try:
adapter = self._adapters[relation_name](relation) cls = self._adapters[relation_name]
except KeyError: 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 return relation_name, adapter

View File

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