From 22265186125557a53ae77df8f44d0d93f9cb7db5 Mon Sep 17 00:00:00 2001 From: David Ames Date: Fri, 25 Aug 2017 16:30:02 -0700 Subject: [PATCH] Use ApacheSSLContext to enable SSL object storage Enable SSL object storage using ApacheSSLContext. Change-Id: Id044afc8c07696a5447eb9dc4836470203372090 Closes-Bug: #1690826 Closes-Bug: #1708464 --- hooks/ceph_radosgw_context.py | 11 ++- hooks/hooks.py | 46 +++++++--- hooks/utils.py | 73 +++++++++++++--- unit_tests/test_ceph_radosgw_context.py | 6 ++ unit_tests/test_ceph_radosgw_utils.py | 108 ++++++++++-------------- unit_tests/test_hooks.py | 11 ++- 6 files changed, 164 insertions(+), 91 deletions(-) diff --git a/hooks/ceph_radosgw_context.py b/hooks/ceph_radosgw_context.py index ae6879e0..ce7975c1 100644 --- a/hooks/ceph_radosgw_context.py +++ b/hooks/ceph_radosgw_context.py @@ -41,6 +41,15 @@ from charmhelpers.contrib.network.ip import ( from charmhelpers.contrib.storage.linux.ceph import CephConfContext +class ApacheSSLContext(context.ApacheSSLContext): + interfaces = ['https'] + service_namespace = 'ceph-radosgw' + + def __call__(self): + self.external_ports = [config('port')] + return super(ApacheSSLContext, self).__call__() + + class HAProxyContext(context.HAProxyContext): def __call__(self): @@ -163,7 +172,7 @@ class MonContext(context.CephContext): if config('prefer-ipv6'): ensure_host_resolvable_v6(host) - port = determine_apache_port(config('port'), singlenode_mode=True) + port = determine_api_port(config('port'), singlenode_mode=True) if config('prefer-ipv6'): port = "[::]:%s" % (port) diff --git a/hooks/hooks.py b/hooks/hooks.py index 5aca8df1..ea1fdaec 100755 --- a/hooks/hooks.py +++ b/hooks/hooks.py @@ -44,6 +44,7 @@ from charmhelpers.payload.execd import execd_preinstall from charmhelpers.core.host import ( cmp_pkgrevno, is_container, + service_reload, ) from charmhelpers.contrib.network.ip import ( get_relation_ip, @@ -78,6 +79,7 @@ from utils import ( services, assess_status, setup_keystone_certs, + disable_unused_apache_sites, ) from charmhelpers.contrib.charmsupport import nrpe from charmhelpers.contrib.hardening.harden import harden @@ -96,11 +98,11 @@ PACKAGES = [ # since python-keystoneclient does not pull in icehouse # version 'radosgw', + 'apache2' ] APACHE_PACKAGES = [ 'libapache2-mod-fastcgi', - 'apache2', ] @@ -114,6 +116,7 @@ def install_packages(): status_set('maintenance', 'Installing radosgw packages') apt_install(PACKAGES, fatal=True) apt_purge(APACHE_PACKAGES) + disable_unused_apache_sites() @hooks.hook('install.real') @@ -136,6 +139,7 @@ def install(): @harden() def config_changed(): install_packages() + disable_unused_apache_sites() if config('prefer-ipv6'): status_set('maintenance', 'configuring ipv6') @@ -154,6 +158,7 @@ def config_changed(): mon_relation(r_id, unit) CONFIGS.write_all() + configure_https() update_nrpe_config() @@ -205,11 +210,11 @@ def identity_joined(relid=None): sys.exit(1) port = config('port') - admin_url = '%s:%i/swift' % (canonical_url(None, ADMIN), port) + admin_url = '%s:%i/swift' % (canonical_url(CONFIGS, ADMIN), port) internal_url = '%s:%s/swift/v1' % \ - (canonical_url(None, INTERNAL), port) + (canonical_url(CONFIGS, INTERNAL), port) public_url = '%s:%s/swift/v1' % \ - (canonical_url(None, PUBLIC), port) + (canonical_url(CONFIGS, PUBLIC), port) relation_set(service='swift', region=config('region'), public_url=public_url, internal_url=internal_url, @@ -217,12 +222,6 @@ def identity_joined(relid=None): requested_roles=config('operator-roles'), relation_id=relid) - if relid: - for unit in related_units(relid): - setup_keystone_certs(unit=unit, rid=relid) - else: - setup_keystone_certs() - @hooks.hook('identity-service-relation-changed') @restart_on_change({'/etc/ceph/ceph.conf': ['radosgw']}) @@ -231,6 +230,7 @@ def identity_changed(relid=None): CONFIGS.write_all() if not is_unit_paused_set(): restart() + configure_https() @hooks.hook('cluster-relation-joined') @@ -351,6 +351,32 @@ def update_nrpe_config(): nrpe_setup.write() +def configure_https(): + '''Enables SSL API Apache config if appropriate and kicks + identity-service and image-service with any required + updates + ''' + CONFIGS.write_all() + if 'https' in CONFIGS.complete_contexts(): + cmd = ['a2ensite', 'openstack_https_frontend'] + subprocess.check_call(cmd) + else: + cmd = ['a2dissite', 'openstack_https_frontend'] + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + # The site is not yet enabled or + # https is not configured + pass + + # TODO: improve this by checking if local CN certs are available + # first then checking reload status (see LP #1433114). + if not is_unit_paused_set(): + service_reload('apache2', restart_on_failure=True) + + setup_keystone_certs(CONFIGS) + + @hooks.hook('update-status') @harden() def update_status(): diff --git a/hooks/utils.py b/hooks/utils.py index 8c5f55d5..2349446e 100644 --- a/hooks/utils.py +++ b/hooks/utils.py @@ -28,6 +28,7 @@ from charmhelpers.core.hookenv import ( INFO, relation_get, relation_ids, + related_units, application_version_set, ) from charmhelpers.contrib.network.ip import ( @@ -46,7 +47,10 @@ from charmhelpers.contrib.openstack.utils import ( from charmhelpers.contrib.openstack.keystone import ( format_endpoint, ) -from charmhelpers.contrib.hahelpers.cluster import get_hacluster_config +from charmhelpers.contrib.hahelpers.cluster import ( + get_hacluster_config, + https, +) from charmhelpers.core.host import ( cmp_pkgrevno, lsb_release, @@ -104,6 +108,12 @@ CEPH_CONF = '/etc/ceph/ceph.conf' VERSION_PACKAGE = 'radosgw' +UNUSED_APACHE_SITE_FILES = ["/etc/apache2/sites-available/000-default.conf"] +APACHE_PORTS_FILE = "/etc/apache2/ports.conf" +APACHE_SITE_CONF = '/etc/apache2/sites-available/openstack_https_frontend' +APACHE_SITE_24_CONF = '/etc/apache2/sites-available/' \ + 'openstack_https_frontend.conf' + BASE_RESOURCE_MAP = OrderedDict([ (HAPROXY_CONF, { 'contexts': [context.HAProxyContext(singlenode_mode=True), @@ -114,6 +124,14 @@ BASE_RESOURCE_MAP = OrderedDict([ 'contexts': [ceph_radosgw_context.MonContext()], 'services': ['radosgw'], }), + (APACHE_SITE_CONF, { + 'contexts': [ceph_radosgw_context.ApacheSSLContext()], + 'services': ['apache2'], + }), + (APACHE_SITE_24_CONF, { + 'contexts': [ceph_radosgw_context.ApacheSSLContext()], + 'services': ['apache2'], + }), ]) @@ -131,6 +149,12 @@ def resource_map(): These will be managed for a single hook execution. """ resource_map = deepcopy(BASE_RESOURCE_MAP) + + if os.path.exists('/etc/apache2/conf-available'): + resource_map.pop(APACHE_SITE_CONF) + else: + resource_map.pop(APACHE_SITE_24_CONF) + return resource_map @@ -156,7 +180,10 @@ def services(): _services = [] for v in BASE_RESOURCE_MAP.values(): _services.extend(v.get('services', [])) - return list(set(_services)) + _set_services = set(_services) + if not https(): + _set_services.remove('apache2') + return list(_set_services) def enable_pocket(pocket): @@ -370,7 +397,9 @@ def get_ks_ca_cert(admin_token, auth_endpoint, certs_path): :param certs_path: Path to local certs store :returns: None """ - ksclient = client.Client(token=admin_token, endpoint=auth_endpoint) + + ksclient = keystoneclient.httpclient.HTTPClient(token=admin_token, + endpoint=auth_endpoint) ca_cert = get_ks_cert(ksclient, auth_endpoint, 'ca') if ca_cert: try: @@ -428,7 +457,7 @@ def get_ks_signing_cert(admin_token, auth_endpoint, certs_path): @defer_if_unavailable(['keystoneclient']) -def setup_keystone_certs(unit=None, rid=None): +def setup_keystone_certs(CONFIGS): """ Get CA and signing certs from Keystone used to decrypt revoked token list. @@ -440,14 +469,20 @@ def setup_keystone_certs(unit=None, rid=None): if not os.path.exists(certs_path): mkdir(certs_path) - rdata = relation_get(unit=unit, rid=rid) - required = ['admin_token', 'auth_host', 'auth_port', 'api_version'] - settings = {key: rdata.get(key) for key in required} - if not all(settings.values()): - log("Missing relation settings ({}) - deferring cert setup".format( - ', '.join([k for k in settings if not settings[k]])), + # Do not continue until identity-relation is complete + if 'identity-service' not in CONFIGS.complete_contexts(): + log("Missing relation settings - deferring cert setup", level=DEBUG) return + rdata = {} + for relid in relation_ids('identity-service'): + for unit in related_units(relid): + rdata = relation_get(unit=unit, rid=relid) + if rdata: + break + + required = ['admin_token', 'auth_host', 'auth_port', 'api_version'] + settings = {key: rdata.get(key) for key in required} auth_protocol = rdata.get('auth_protocol', 'http') if is_ipv6(settings.get('auth_host')): @@ -463,3 +498,21 @@ def setup_keystone_certs(unit=None, rid=None): get_ks_signing_cert(settings['admin_token'], auth_endpoint, certs_path) except KSCertSetupException as e: log("Keystone certs setup incomplete - {}".format(e), level=INFO) + + +def disable_unused_apache_sites(): + """Ensure that unused apache configurations are disabled to prevent them + from conflicting with the charm-provided version. + """ + for apache_site_file in UNUSED_APACHE_SITE_FILES: + apache_site = apache_site_file.split('/')[-1].split('.')[0] + if os.path.exists(apache_site_file): + try: + # Try it cleanly + subprocess.check_call(['a2dissite', apache_site]) + except subprocess.CalledProcessError: + # Remove the file + os.remove(apache_site_file) + + with open(APACHE_PORTS_FILE, 'w') as ports: + ports.write("") diff --git a/unit_tests/test_ceph_radosgw_context.py b/unit_tests/test_ceph_radosgw_context.py index f84fc692..a69cb62d 100644 --- a/unit_tests/test_ceph_radosgw_context.py +++ b/unit_tests/test_ceph_radosgw_context.py @@ -29,6 +29,7 @@ TO_PATCH = [ 'cmp_pkgrevno', 'socket', 'unit_public_ip', + 'determine_api_port' ] @@ -54,6 +55,7 @@ class HAProxyContextTests(CharmTestCase): _haconfig.side_effect = self.test_config.get _harelation_ids.return_value = [] haproxy_context = context.HAProxyContext() + self.determine_api_port.return_value = 70 expect = { 'cephradosgw_bind_port': 70, 'service_ports': {'cephradosgw-server': [80, 70]} @@ -190,6 +192,7 @@ class MonContextTest(CharmTestCase): self.relation_get.side_effect = _relation_get self.relation_ids.return_value = ['mon:6'] self.related_units.return_value = ['ceph/0', 'ceph/1', 'ceph/2'] + self.determine_api_port.return_value = 70 expect = { 'auth_supported': 'cephx', 'hostname': 'testhost', @@ -229,6 +232,7 @@ class MonContextTest(CharmTestCase): self.relation_get.side_effect = _relation_get self.relation_ids.return_value = ['mon:6'] self.related_units.return_value = ['ceph-proxy/0'] + self.determine_api_port.return_value = 70 expect = { 'auth_supported': 'cephx', 'hostname': 'testhost', @@ -277,6 +281,7 @@ class MonContextTest(CharmTestCase): self.relation_get.side_effect = _relation_get self.relation_ids.return_value = ['mon:6'] self.related_units.return_value = ['ceph/0', 'ceph/1', 'ceph/2'] + self.determine_api_port.return_value = 70 expect = { 'auth_supported': 'none', 'hostname': 'testhost', @@ -307,6 +312,7 @@ class MonContextTest(CharmTestCase): self.relation_get.side_effect = _relation_get self.relation_ids.return_value = ['mon:6'] self.related_units.return_value = ['ceph/0', 'ceph/1', 'ceph/2'] + self.determine_api_port.return_value = 70 expect = { 'auth_supported': 'cephx', 'hostname': 'testhost', diff --git a/unit_tests/test_ceph_radosgw_utils.py b/unit_tests/test_ceph_radosgw_utils.py index 37ed1d6a..93576b21 100644 --- a/unit_tests/test_ceph_radosgw_utils.py +++ b/unit_tests/test_ceph_radosgw_utils.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys - from mock import ( call, patch, @@ -29,6 +27,7 @@ TO_PATCH = [ 'application_version_set', 'get_upstream_version', 'format_endpoint', + 'https', ] @@ -90,8 +89,11 @@ class CephRadosGWUtilTests(CharmTestCase): # ports=None whilst port checks are disabled. f.assert_called_once_with('assessor', services='s1', ports=None) + @patch.object(utils, 'related_units') + @patch.object(utils, 'relation_ids') @patch.dict('sys.modules', {'requests': MagicMock(), - 'keystoneclient': MagicMock()}) + 'keystoneclient': MagicMock(), + 'httpclient': MagicMock()}) @patch.object(utils, 'is_ipv6', lambda addr: False) @patch.object(utils, 'get_ks_signing_cert') @patch.object(utils, 'get_ks_ca_cert') @@ -99,93 +101,71 @@ class CephRadosGWUtilTests(CharmTestCase): @patch.object(utils, 'mkdir') def test_setup_keystone_certs(self, mock_mkdir, mock_relation_get, mock_get_ks_ca_cert, - mock_get_ks_signing_cert): + mock_get_ks_signing_cert, + mock_relation_ids, mock_related_units): auth_host = 'foo/bar' auth_port = 80 admin_token = '666' auth_url = 'http://%s:%s/v2.0' % (auth_host, auth_port) self.format_endpoint.return_value = auth_url + configs = MagicMock() + configs.complete_contexts.return_value = ['identity-service'] + mock_relation_ids.return_value = ['identity-service:5'] + mock_related_units.return_value = ['keystone/1'] mock_relation_get.return_value = {'auth_host': auth_host, 'auth_port': auth_port, 'admin_token': admin_token, 'api_version': '2'} - utils.setup_keystone_certs() + utils.setup_keystone_certs(configs) mock_get_ks_signing_cert.assert_has_calls([call(admin_token, auth_url, '/var/lib/ceph/nss')]) mock_get_ks_ca_cert.assert_has_calls([call(admin_token, auth_url, '/var/lib/ceph/nss')]) - def test_get_ks_signing_cert(self): + @patch.object(utils, 'get_ks_cert') + @patch.object(utils.subprocess, 'Popen') + @patch.object(utils.subprocess, 'check_output') + def test_get_ks_signing_cert(self, mock_check_output, mock_Popen, + mock_get_ks_cert): auth_host = 'foo/bar' auth_port = 80 admin_token = '666' auth_url = 'http://%s:%s/v2.0' % (auth_host, auth_port) - mock_ksclient = MagicMock m = mock_open() - with patch.dict('sys.modules', - {'requests': MagicMock(), - 'keystoneclient': mock_ksclient, - 'keystoneclient.exceptions': MagicMock(), - 'keystoneclient.exceptions.ConnectionRefused': - MagicMock(), - 'keystoneclient.exceptions.Forbidden': MagicMock(), - 'keystoneclient.v2_0': MagicMock(), - 'keystoneclient.v2_0.client': MagicMock()}): - # Reimport - del sys.modules['utils'] - import utils - with patch.object(utils, 'subprocess') as mock_subprocess: - with patch.object(utils, 'open', m, create=True): - mock_certificates = MagicMock() - mock_ksclient.certificates = mock_certificates - mock_certificates.get_signing_certificate.return_value = \ - 'signing_cert_data' - utils.get_ks_signing_cert(admin_token, auth_url, - '/foo/bar') - mock_certificates.get_signing_certificate.return_value = \ - None - self.assertRaises(utils.KSCertSetupException, - utils.get_ks_signing_cert, admin_token, - auth_url, '/foo/bar') + with patch.object(utils, 'open', m, create=True): - c = ['openssl', 'x509', '-in', - '/foo/bar/signing_certificate.pem', - '-pubkey'] - mock_subprocess.check_output.assert_called_with(c) + mock_get_ks_cert.return_value = 'signing_cert_data' + utils.get_ks_signing_cert(admin_token, auth_url, '/foo/bar') - def test_get_ks_ca_cert(self): + mock_get_ks_cert.return_value = None + with self.assertRaises(utils.KSCertSetupException): + utils.get_ks_signing_cert(admin_token, auth_url, '/foo/bar') + + c = ['openssl', 'x509', '-in', + '/foo/bar/signing_certificate.pem', + '-pubkey'] + mock_check_output.assert_called_with(c) + + @patch.object(utils, 'get_ks_cert') + @patch.object(utils.subprocess, 'Popen') + @patch.object(utils.subprocess, 'check_output') + def test_get_ks_ca_cert(self, mock_check_output, mock_Popen, + mock_get_ks_cert): auth_host = 'foo/bar' auth_port = 80 admin_token = '666' auth_url = 'http://%s:%s/v2.0' % (auth_host, auth_port) - mock_ksclient = MagicMock m = mock_open() - with patch.dict('sys.modules', - {'requests': MagicMock(), - 'keystoneclient': mock_ksclient, - 'keystoneclient.exceptions': MagicMock(), - 'keystoneclient.exceptions.ConnectionRefused': - MagicMock(), - 'keystoneclient.exceptions.Forbidden': MagicMock(), - 'keystoneclient.v2_0': MagicMock(), - 'keystoneclient.v2_0.client': MagicMock()}): - # Reimport - del sys.modules['utils'] - import utils - with patch.object(utils, 'subprocess') as mock_subprocess: - with patch.object(utils, 'open', m, create=True): - mock_certificates = MagicMock() - mock_ksclient.certificates = mock_certificates - mock_certificates.get_ca_certificate.return_value = \ - 'ca_cert_data' - utils.get_ks_ca_cert(admin_token, auth_url, '/foo/bar') - mock_certificates.get_ca_certificate.return_value = None - self.assertRaises(utils.KSCertSetupException, - utils.get_ks_ca_cert, admin_token, - auth_url, '/foo/bar') + with patch.object(utils, 'open', m, create=True): + mock_get_ks_cert.return_value = 'ca_cert_data' + utils.get_ks_ca_cert(admin_token, auth_url, '/foo/bar') - c = ['openssl', 'x509', '-in', '/foo/bar/ca.pem', - '-pubkey'] - mock_subprocess.check_output.assert_called_with(c) + mock_get_ks_cert.return_value = None + with self.assertRaises(utils.KSCertSetupException): + utils.get_ks_ca_cert(admin_token, auth_url, '/foo/bar') + + c = ['openssl', 'x509', '-in', '/foo/bar/ca.pem', + '-pubkey'] + mock_check_output.assert_called_with(c) diff --git a/unit_tests/test_hooks.py b/unit_tests/test_hooks.py index a379b4e5..08c677eb 100644 --- a/unit_tests/test_hooks.py +++ b/unit_tests/test_hooks.py @@ -53,6 +53,9 @@ TO_PATCH = [ 'get_hacluster_config', 'update_dns_ha_resource_params', 'get_relation_ip', + 'disable_unused_apache_sites', + 'service_reload', + 'setup_keystone_certs', ] @@ -69,8 +72,7 @@ class CephRadosGWTests(CharmTestCase): ceph_hooks.install_packages() self.add_source.assert_called_with('distro', 'secretkey') self.assertTrue(self.apt_update.called) - self.apt_purge.assert_called_with(['libapache2-mod-fastcgi', - 'apache2']) + self.apt_purge.assert_called_with(['libapache2-mod-fastcgi']) def test_install(self): _install_packages = self.patch('install_packages') @@ -144,15 +146,12 @@ class CephRadosGWTests(CharmTestCase): cmd = ['service', 'radosgw', 'restart'] self.subprocess.call.assert_called_with(cmd) - @patch.object(ceph_hooks, 'setup_keystone_certs') @patch('charmhelpers.contrib.openstack.ip.service_name', lambda *args: 'ceph-radosgw') @patch('charmhelpers.contrib.openstack.ip.config') - def test_identity_joined_early_version(self, _config, - mock_setup_keystone_certs): + def test_identity_joined_early_version(self, _config): self.cmp_pkgrevno.return_value = -1 ceph_hooks.identity_joined() - self.assertTrue(mock_setup_keystone_certs.called) self.sys.exit.assert_called_with(1) @patch('charmhelpers.contrib.openstack.ip.service_name',