From 50ed0bdbaefcac24f722a7113920d502803c3fed Mon Sep 17 00:00:00 2001 From: Anton Arefiev Date: Thu, 16 Mar 2017 09:59:20 +0200 Subject: [PATCH] Preparing for service splitting Creates new WSGIService class which keeps base API sercice initialization functionality and serve flask application. Also it will configure application for wsgi container[1]. Also creates new `cmd` directory for storing console scripts. [1] https://governance.openstack.org/tc/goals/pike/deploy-api-in-wsgi.html Related-Bug: #1525218 Change-Id: Ia64228c47a79a3008d435e8323a964f2bc45dfa7 --- ironic_inspector/cmd/__init__.py | 2 + ironic_inspector/cmd/all.py | 29 +++ ironic_inspector/common/service_utils.py | 35 +++ ironic_inspector/main.py | 168 -------------- ironic_inspector/test/functional.py | 3 +- ironic_inspector/test/unit/test_main.py | 156 ------------- .../test/unit/test_wsgi_service.py | 207 ++++++++++++++++++ ironic_inspector/wsgi_service.py | 196 +++++++++++++++++ setup.cfg | 2 +- 9 files changed, 472 insertions(+), 326 deletions(-) create mode 100644 ironic_inspector/cmd/__init__.py create mode 100644 ironic_inspector/cmd/all.py create mode 100644 ironic_inspector/common/service_utils.py create mode 100644 ironic_inspector/test/unit/test_wsgi_service.py create mode 100644 ironic_inspector/wsgi_service.py diff --git a/ironic_inspector/cmd/__init__.py b/ironic_inspector/cmd/__init__.py new file mode 100644 index 000000000..882e5dfde --- /dev/null +++ b/ironic_inspector/cmd/__init__.py @@ -0,0 +1,2 @@ +import eventlet # noqa +eventlet.monkey_patch() diff --git a/ironic_inspector/cmd/all.py b/ironic_inspector/cmd/all.py new file mode 100644 index 000000000..2475e9721 --- /dev/null +++ b/ironic_inspector/cmd/all.py @@ -0,0 +1,29 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""The Ironic Inspector service.""" + +import sys + +from ironic_inspector.common import service_utils +from ironic_inspector import wsgi_service + + +def main(args=sys.argv[1:]): + # Parse config file and command line options, then start logging + service_utils.prepare_service(args) + + server = wsgi_service.WSGIService() + server.run() + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ironic_inspector/common/service_utils.py b/ironic_inspector/common/service_utils.py new file mode 100644 index 000000000..f41f24d51 --- /dev/null +++ b/ironic_inspector/common/service_utils.py @@ -0,0 +1,35 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg +from oslo_log import log + + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +def prepare_service(args): + log.register_options(CONF) + log.set_defaults(default_log_levels=['sqlalchemy=WARNING', + 'iso8601=WARNING', + 'requests=WARNING', + 'urllib3.connectionpool=WARNING', + 'keystonemiddleware=WARNING', + 'swiftclient=WARNING', + 'keystoneauth=WARNING', + 'ironicclient=WARNING']) + CONF(args, project='ironic-inspector') + log.setup(CONF, 'ironic_inspector') + + LOG.debug("Configuration:") + CONF.log_opt_values(LOG, log.DEBUG) diff --git a/ironic_inspector/main.py b/ironic_inspector/main.py index fa30f0eae..d65df14c3 100644 --- a/ironic_inspector/main.py +++ b/ironic_inspector/main.py @@ -11,19 +11,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -import eventlet # noqa -eventlet.monkey_patch() - import functools import os import re -import ssl -import sys import flask -from futurist import periodics from oslo_config import cfg -from oslo_log import log from oslo_utils import uuidutils import werkzeug @@ -32,11 +25,8 @@ from ironic_inspector.common.i18n import _ from ironic_inspector.common import ironic as ir_utils from ironic_inspector.common import swift from ironic_inspector import conf # noqa -from ironic_inspector import db -from ironic_inspector import firewall from ironic_inspector import introspect from ironic_inspector import node_cache -from ironic_inspector.plugins import base as plugins_base from ironic_inspector import process from ironic_inspector import rules from ironic_inspector import utils @@ -350,161 +340,3 @@ def api_rule(uuid): @app.errorhandler(404) def handle_404(error): return error_response(error, code=404) - - -def periodic_update(): # pragma: no cover - try: - firewall.update_filters() - except Exception: - LOG.exception('Periodic update of firewall rules failed') - - -def periodic_clean_up(): # pragma: no cover - try: - if node_cache.clean_up(): - firewall.update_filters() - sync_with_ironic() - except Exception: - LOG.exception('Periodic clean up of node cache failed') - - -def sync_with_ironic(): - ironic = ir_utils.get_client() - # TODO(yuikotakada): pagination - ironic_nodes = ironic.node.list(limit=0) - ironic_node_uuids = {node.uuid for node in ironic_nodes} - node_cache.delete_nodes_not_in_list(ironic_node_uuids) - - -def create_ssl_context(): - if not CONF.use_ssl: - return - - MIN_VERSION = (2, 7, 9) - - if sys.version_info < MIN_VERSION: - LOG.warning('Unable to use SSL in this version of Python: ' - '%(current)s, please ensure your version of Python is ' - 'greater than %(min)s to enable this feature.', - {'current': '.'.join(map(str, sys.version_info[:3])), - 'min': '.'.join(map(str, MIN_VERSION))}) - return - - context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) - if CONF.ssl_cert_path and CONF.ssl_key_path: - try: - context.load_cert_chain(CONF.ssl_cert_path, CONF.ssl_key_path) - except IOError as exc: - LOG.warning('Failed to load certificate or key from defined ' - 'locations: %(cert)s and %(key)s, will continue ' - 'to run with the default settings: %(exc)s', - {'cert': CONF.ssl_cert_path, 'key': CONF.ssl_key_path, - 'exc': exc}) - except ssl.SSLError as exc: - LOG.warning('There was a problem with the loaded certificate ' - 'and key, will continue to run with the default ' - 'settings: %s', exc) - return context - - -class Service(object): - _periodics_worker = None - - def setup_logging(self, args): - log.register_options(CONF) - CONF(args, project='ironic-inspector') - - log.set_defaults(default_log_levels=[ - 'sqlalchemy=WARNING', - 'iso8601=WARNING', - 'requests=WARNING', - 'urllib3.connectionpool=WARNING', - 'keystonemiddleware=WARNING', - 'swiftclient=WARNING', - 'keystoneauth=WARNING', - 'ironicclient=WARNING' - ]) - log.setup(CONF, 'ironic_inspector') - - LOG.debug("Configuration:") - CONF.log_opt_values(LOG, log.DEBUG) - - def init(self): - if CONF.auth_strategy != 'noauth': - utils.add_auth_middleware(app) - else: - LOG.warning('Starting unauthenticated, please check' - ' configuration') - - if CONF.processing.store_data == 'none': - LOG.warning('Introspection data will not be stored. Change ' - '"[processing] store_data" option if this is not ' - 'the desired behavior') - elif CONF.processing.store_data == 'swift': - LOG.info('Introspection data will be stored in Swift in the ' - 'container %s', CONF.swift.container) - - utils.add_cors_middleware(app) - - db.init() - - try: - hooks = plugins_base.validate_processing_hooks() - except Exception as exc: - LOG.critical(str(exc)) - sys.exit(1) - - LOG.info('Enabled processing hooks: %s', [h.name for h in hooks]) - - if CONF.firewall.manage_firewall: - firewall.init() - - periodic_update_ = periodics.periodic( - spacing=CONF.firewall.firewall_update_period, - enabled=CONF.firewall.manage_firewall - )(periodic_update) - periodic_clean_up_ = periodics.periodic( - spacing=CONF.clean_up_period - )(periodic_clean_up) - - self._periodics_worker = periodics.PeriodicWorker( - callables=[(periodic_update_, None, None), - (periodic_clean_up_, None, None)], - executor_factory=periodics.ExistingExecutor(utils.executor())) - utils.executor().submit(self._periodics_worker.start) - - def shutdown(self): - LOG.debug('Shutting down') - - firewall.clean_up() - - if self._periodics_worker is not None: - self._periodics_worker.stop() - self._periodics_worker.wait() - self._periodics_worker = None - - if utils.executor().alive: - utils.executor().shutdown(wait=True) - - LOG.info('Shut down successfully') - - def run(self, args, application): - self.setup_logging(args) - - app_kwargs = {'host': CONF.listen_address, - 'port': CONF.listen_port} - - context = create_ssl_context() - if context: - app_kwargs['ssl_context'] = context - - self.init() - try: - application.run(**app_kwargs) - finally: - self.shutdown() - - -def main(args=sys.argv[1:]): # pragma: no cover - service = Service() - service.run(args, app) diff --git a/ironic_inspector/test/functional.py b/ironic_inspector/test/functional.py index 12a37ffea..e23d31992 100644 --- a/ironic_inspector/test/functional.py +++ b/ironic_inspector/test/functional.py @@ -34,6 +34,7 @@ import requests import six from six.moves import urllib +from ironic_inspector.cmd import all as inspector_cmd from ironic_inspector.common import ironic as ir_utils from ironic_inspector.common import swift from ironic_inspector import db @@ -779,7 +780,7 @@ def mocked_server(): cfg.CONF.reset() cfg.CONF.unregister_opt(dbsync.command_opt) - eventlet.greenthread.spawn_n(main.main, + eventlet.greenthread.spawn_n(inspector_cmd.main, args=['--config-file', conf_file]) eventlet.greenthread.sleep(1) # Wait for service to start up to 30 seconds diff --git a/ironic_inspector/test/unit/test_main.py b/ironic_inspector/test/unit/test_main.py index dc02c88e7..463f65da0 100644 --- a/ironic_inspector/test/unit/test_main.py +++ b/ironic_inspector/test/unit/test_main.py @@ -13,8 +13,6 @@ import datetime import json -import ssl -import sys import unittest import mock @@ -22,8 +20,6 @@ from oslo_utils import uuidutils from ironic_inspector.common import ironic as ir_utils from ironic_inspector import conf -from ironic_inspector import db -from ironic_inspector import firewall from ironic_inspector import introspect from ironic_inspector import introspection_state as istate from ironic_inspector import main @@ -648,155 +644,3 @@ class TestPlugins(unittest.TestCase): def test_manager_is_cached(self): self.assertIs(plugins_base.processing_hooks_manager(), plugins_base.processing_hooks_manager()) - - -@mock.patch.object(firewall, 'init') -@mock.patch.object(utils, 'add_auth_middleware') -@mock.patch.object(ir_utils, 'get_client') -@mock.patch.object(db, 'init') -class TestInit(test_base.BaseTest): - def setUp(self): - super(TestInit, self).setUp() - # Tests default to a synchronous executor which can't be used here - utils._EXECUTOR = None - self.service = main.Service() - - @mock.patch.object(firewall, 'clean_up', lambda: None) - def tearDown(self): - self.service.shutdown() - super(TestInit, self).tearDown() - - def test_ok(self, mock_node_cache, mock_get_client, mock_auth, - mock_firewall): - CONF.set_override('auth_strategy', 'keystone') - self.service.init() - mock_auth.assert_called_once_with(main.app) - mock_node_cache.assert_called_once_with() - mock_firewall.assert_called_once_with() - - def test_init_without_authenticate(self, mock_node_cache, mock_get_client, - mock_auth, mock_firewall): - CONF.set_override('auth_strategy', 'noauth') - self.service.init() - self.assertFalse(mock_auth.called) - - @mock.patch.object(main.LOG, 'warning') - def test_init_with_no_data_storage(self, mock_log, mock_node_cache, - mock_get_client, mock_auth, - mock_firewall): - msg = ('Introspection data will not be stored. Change ' - '"[processing] store_data" option if this is not the ' - 'desired behavior') - self.service.init() - mock_log.assert_called_once_with(msg) - - @mock.patch.object(main.LOG, 'info') - def test_init_with_swift_storage(self, mock_log, mock_node_cache, - mock_get_client, mock_auth, - mock_firewall): - CONF.set_override('store_data', 'swift', 'processing') - msg = mock.call('Introspection data will be stored in Swift in the ' - 'container %s', CONF.swift.container) - self.service.init() - self.assertIn(msg, mock_log.call_args_list) - - def test_init_without_manage_firewall(self, mock_node_cache, - mock_get_client, mock_auth, - mock_firewall): - CONF.set_override('manage_firewall', False, 'firewall') - self.service.init() - self.assertFalse(mock_firewall.called) - - @mock.patch.object(main.LOG, 'critical') - def test_init_failed_processing_hook(self, mock_log, mock_node_cache, - mock_get_client, mock_auth, - mock_firewall): - CONF.set_override('processing_hooks', 'foo!', 'processing') - plugins_base._HOOKS_MGR = None - - self.assertRaises(SystemExit, self.service.init) - mock_log.assert_called_once_with( - 'The following hook(s) are missing or failed to load: foo!') - - -class TestCreateSSLContext(test_base.BaseTest): - - def test_use_ssl_false(self): - CONF.set_override('use_ssl', False) - con = main.create_ssl_context() - self.assertIsNone(con) - - @mock.patch.object(sys, 'version_info') - def test_old_python_returns_none(self, mock_version_info): - mock_version_info.__lt__.return_value = True - CONF.set_override('use_ssl', True) - con = main.create_ssl_context() - self.assertIsNone(con) - - @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), - 'This feature is unsupported in this version of python ' - 'so the tests will be skipped') - @mock.patch.object(ssl, 'create_default_context', autospec=True) - def test_use_ssl_true(self, mock_cdc): - CONF.set_override('use_ssl', True) - m_con = mock_cdc() - con = main.create_ssl_context() - self.assertEqual(m_con, con) - - @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), - 'This feature is unsupported in this version of python ' - 'so the tests will be skipped') - @mock.patch.object(ssl, 'create_default_context', autospec=True) - def test_only_key_path_provided(self, mock_cdc): - CONF.set_override('use_ssl', True) - CONF.set_override('ssl_key_path', '/some/fake/path') - mock_context = mock_cdc() - con = main.create_ssl_context() - self.assertEqual(mock_context, con) - self.assertFalse(mock_context.load_cert_chain.called) - - @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), - 'This feature is unsupported in this version of python ' - 'so the tests will be skipped') - @mock.patch.object(ssl, 'create_default_context', autospec=True) - def test_only_cert_path_provided(self, mock_cdc): - CONF.set_override('use_ssl', True) - CONF.set_override('ssl_cert_path', '/some/fake/path') - mock_context = mock_cdc() - con = main.create_ssl_context() - self.assertEqual(mock_context, con) - self.assertFalse(mock_context.load_cert_chain.called) - - @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), - 'This feature is unsupported in this version of python ' - 'so the tests will be skipped') - @mock.patch.object(ssl, 'create_default_context', autospec=True) - def test_both_paths_provided(self, mock_cdc): - key_path = '/some/fake/path/key' - cert_path = '/some/fake/path/cert' - CONF.set_override('use_ssl', True) - CONF.set_override('ssl_key_path', key_path) - CONF.set_override('ssl_cert_path', cert_path) - mock_context = mock_cdc() - con = main.create_ssl_context() - self.assertEqual(mock_context, con) - mock_context.load_cert_chain.assert_called_once_with(cert_path, - key_path) - - @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), - 'This feature is unsupported in this version of python ' - 'so the tests will be skipped') - @mock.patch.object(ssl, 'create_default_context', autospec=True) - def test_load_cert_chain_fails(self, mock_cdc): - CONF.set_override('use_ssl', True) - key_path = '/some/fake/path/key' - cert_path = '/some/fake/path/cert' - CONF.set_override('use_ssl', True) - CONF.set_override('ssl_key_path', key_path) - CONF.set_override('ssl_cert_path', cert_path) - mock_context = mock_cdc() - mock_context.load_cert_chain.side_effect = IOError('Boom!') - con = main.create_ssl_context() - self.assertEqual(mock_context, con) - mock_context.load_cert_chain.assert_called_once_with(cert_path, - key_path) diff --git a/ironic_inspector/test/unit/test_wsgi_service.py b/ironic_inspector/test/unit/test_wsgi_service.py new file mode 100644 index 000000000..52b3767f9 --- /dev/null +++ b/ironic_inspector/test/unit/test_wsgi_service.py @@ -0,0 +1,207 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ssl +import sys +import unittest + +import eventlet # noqa +import fixtures +import mock +from oslo_config import cfg + +from ironic_inspector import db +from ironic_inspector import firewall +from ironic_inspector import main +from ironic_inspector.plugins import base as plugins_base +from ironic_inspector.test import base as test_base +from ironic_inspector import utils +from ironic_inspector import wsgi_service + + +CONF = cfg.CONF + + +@mock.patch.object(firewall, 'clean_up', lambda: None) +@mock.patch.object(db, 'init', lambda: None) +@mock.patch.object(wsgi_service.WSGIService, '_init_host', lambda x: None) +@mock.patch.object(utils, 'add_auth_middleware') +class TestWSGIService(test_base.BaseTest): + def setUp(self): + super(TestWSGIService, self).setUp() + self.app = self.useFixture(fixtures.MockPatchObject( + main, 'app', autospec=True)).mock + self.service = wsgi_service.WSGIService() + + def test_init_middleware(self, mock_auth): + CONF.set_override('auth_strategy', 'keystone') + self.service._init_middleware() + + mock_auth.assert_called_once_with(self.app) + + @mock.patch.object(wsgi_service.WSGIService, '_init_middleware') + def test_run_ok(self, mock_init_middlw, mock_auth): + self.service.run() + + mock_init_middlw.assert_called_once_with() + self.app.run.assert_called_once_with(host='0.0.0.0', port=5050) + + @mock.patch.object(wsgi_service.LOG, 'info') + def test_init_with_swift_storage(self, mock_log, mock_auth): + + CONF.set_override('store_data', 'swift', 'processing') + msg = mock.call('Introspection data will be stored in Swift in the ' + 'container %s', CONF.swift.container) + self.service.run() + self.assertIn(msg, mock_log.call_args_list) + + def test_init_without_authenticate(self, mock_auth): + + CONF.set_override('auth_strategy', 'noauth') + self.service.run() + self.assertFalse(mock_auth.called) + + @mock.patch.object(wsgi_service.LOG, 'warning') + def test_init_with_no_data_storage(self, mock_log, mock_auth): + msg = ('Introspection data will not be stored. Change ' + '"[processing] store_data" option if this is not the ' + 'desired behavior') + self.service.run() + mock_log.assert_called_once_with(msg) + + +class TestCreateSSLContext(test_base.BaseTest): + def setUp(self): + super(TestCreateSSLContext, self).setUp() + self.app = mock.Mock() + self.service = wsgi_service.WSGIService() + + def test_use_ssl_false(self): + CONF.set_override('use_ssl', False) + con = self.service._create_ssl_context() + self.assertIsNone(con) + + @mock.patch.object(sys, 'version_info') + def test_old_python_returns_none(self, mock_version_info): + mock_version_info.__lt__.return_value = True + CONF.set_override('use_ssl', True) + con = self.service._create_ssl_context() + self.assertIsNone(con) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_use_ssl_true(self, mock_cdc): + CONF.set_override('use_ssl', True) + m_con = mock_cdc() + con = self.service._create_ssl_context() + self.assertEqual(m_con, con) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_only_key_path_provided(self, mock_cdc): + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', '/some/fake/path') + mock_context = mock_cdc() + con = self.service._create_ssl_context() + self.assertEqual(mock_context, con) + self.assertFalse(mock_context.load_cert_chain.called) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_only_cert_path_provided(self, mock_cdc): + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_cert_path', '/some/fake/path') + mock_context = mock_cdc() + con = self.service._create_ssl_context() + self.assertEqual(mock_context, con) + self.assertFalse(mock_context.load_cert_chain.called) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_both_paths_provided(self, mock_cdc): + key_path = '/some/fake/path/key' + cert_path = '/some/fake/path/cert' + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', key_path) + CONF.set_override('ssl_cert_path', cert_path) + mock_context = mock_cdc() + con = self.service._create_ssl_context() + self.assertEqual(mock_context, con) + mock_context.load_cert_chain.assert_called_once_with(cert_path, + key_path) + + @unittest.skipIf(sys.version_info[:3] < (2, 7, 9), + 'This feature is unsupported in this version of python ' + 'so the tests will be skipped') + @mock.patch.object(ssl, 'create_default_context', autospec=True) + def test_load_cert_chain_fails(self, mock_cdc): + CONF.set_override('use_ssl', True) + key_path = '/some/fake/path/key' + cert_path = '/some/fake/path/cert' + CONF.set_override('use_ssl', True) + CONF.set_override('ssl_key_path', key_path) + CONF.set_override('ssl_cert_path', cert_path) + mock_context = mock_cdc() + mock_context.load_cert_chain.side_effect = IOError('Boom!') + con = self.service._create_ssl_context() + self.assertEqual(mock_context, con) + mock_context.load_cert_chain.assert_called_once_with(cert_path, + key_path) + + +@mock.patch.object(firewall, 'init') +@mock.patch.object(db, 'init') +class TestInit(test_base.BaseTest): + def setUp(self): + super(TestInit, self).setUp() + # Tests default to a synchronous executor which can't be used here + utils._EXECUTOR = None + # Monkey patch for periodic tasks + eventlet.monkey_patch() + self.wsgi = wsgi_service.WSGIService() + + @mock.patch.object(firewall, 'clean_up', lambda: None) + def tearDown(self): + self.wsgi.shutdown() + super(TestInit, self).tearDown() + + def test_ok(self, mock_db, mock_firewall): + self.wsgi._init_host() + + mock_db.assert_called_once_with() + mock_firewall.assert_called_once_with() + + def test_init_without_manage_firewall(self, mock_db, mock_firewall): + + CONF.set_override('manage_firewall', False, 'firewall') + self.wsgi._init_host() + self.assertFalse(mock_firewall.called) + + @mock.patch.object(wsgi_service.LOG, 'critical') + def test_init_failed_processing_hook(self, mock_log, + mock_db, mock_firewall): + + CONF.set_override('processing_hooks', 'foo!', 'processing') + plugins_base._HOOKS_MGR = None + + self.assertRaises(SystemExit, self.wsgi._init_host) + mock_log.assert_called_once_with( + 'The following hook(s) are missing or failed to load: foo!') diff --git a/ironic_inspector/wsgi_service.py b/ironic_inspector/wsgi_service.py new file mode 100644 index 000000000..7365c5fe7 --- /dev/null +++ b/ironic_inspector/wsgi_service.py @@ -0,0 +1,196 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import ssl +import sys + +from futurist import periodics +from oslo_config import cfg +from oslo_log import log + +from ironic_inspector.common import ironic as ir_utils +from ironic_inspector import db +from ironic_inspector import firewall +from ironic_inspector import main as app +from ironic_inspector import node_cache +from ironic_inspector.plugins import base as plugins_base +from ironic_inspector import utils + + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +class WSGIService(object): + """Provides ability to launch API from wsgi app.""" + + def __init__(self): + self.app = app.app + self._periodics_worker = None + + def _init_middleware(self): + """Initialize WSGI middleware. + + :returns: None + """ + + if CONF.auth_strategy != 'noauth': + utils.add_auth_middleware(self.app) + else: + LOG.warning('Starting unauthenticated, please check' + ' configuration') + + # TODO(aarefiev): move to WorkerService once we split service + if CONF.processing.store_data == 'none': + LOG.warning('Introspection data will not be stored. Change ' + '"[processing] store_data" option if this is not ' + 'the desired behavior') + elif CONF.processing.store_data == 'swift': + LOG.info('Introspection data will be stored in Swift in the ' + 'container %s', CONF.swift.container) + utils.add_cors_middleware(self.app) + + def _create_ssl_context(self): + if not CONF.use_ssl: + return + + MIN_VERSION = (2, 7, 9) + + if sys.version_info < MIN_VERSION: + LOG.warning(('Unable to use SSL in this version of Python: ' + '%(current)s, please ensure your version of Python ' + 'is greater than %(min)s to enable this feature.'), + {'current': '.'.join(map(str, sys.version_info[:3])), + 'min': '.'.join(map(str, MIN_VERSION))}) + return + + context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + if CONF.ssl_cert_path and CONF.ssl_key_path: + try: + context.load_cert_chain(CONF.ssl_cert_path, CONF.ssl_key_path) + except IOError as exc: + LOG.warning('Failed to load certificate or key from defined ' + 'locations: %(cert)s and %(key)s, will continue ' + 'to run with the default settings: %(exc)s', + {'cert': CONF.ssl_cert_path, + 'key': CONF.ssl_key_path, + 'exc': exc}) + except ssl.SSLError as exc: + LOG.warning('There was a problem with the loaded certificate ' + 'and key, will continue to run with the default ' + 'settings: %s', exc) + return context + + # TODO(aarefiev): move init code to WorkerService + def _init_host(self): + """Initialize Worker host + + Init db connection, load and validate processing + hooks, runs periodic tasks. + + :returns None + """ + db.init() + + try: + hooks = plugins_base.validate_processing_hooks() + except Exception as exc: + LOG.critical(str(exc)) + sys.exit(1) + + LOG.info('Enabled processing hooks: %s', [h.name for h in hooks]) + + if CONF.firewall.manage_firewall: + firewall.init() + + periodic_update_ = periodics.periodic( + spacing=CONF.firewall.firewall_update_period, + enabled=CONF.firewall.manage_firewall + )(periodic_update) + periodic_clean_up_ = periodics.periodic( + spacing=CONF.clean_up_period + )(periodic_clean_up) + + self._periodics_worker = periodics.PeriodicWorker( + callables=[(periodic_update_, None, None), + (periodic_clean_up_, None, None)], + executor_factory=periodics.ExistingExecutor(utils.executor())) + utils.executor().submit(self._periodics_worker.start) + + def shutdown(self): + """Stop serving API, clean up. + + :returns: None + """ + # TODO(aarefiev): move shutdown code to WorkerService + LOG.debug('Shutting down') + + firewall.clean_up() + + if self._periodics_worker is not None: + try: + self._periodics_worker.stop() + self._periodics_worker.wait() + except Exception as e: + LOG.exception('Service error occurred when stopping ' + 'periodic workers. Error: %s', e) + self._periodics_worker = None + + if utils.executor().alive: + utils.executor().shutdown(wait=True) + + LOG.info('Shut down successfully') + + def run(self): + """Start serving this service using loaded application. + + :returns: None + """ + app_kwargs = {'host': CONF.listen_address, + 'port': CONF.listen_port} + + context = self._create_ssl_context() + if context: + app_kwargs['ssl_context'] = context + + self._init_middleware() + + self._init_host() + + try: + self.app.run(**app_kwargs) + finally: + self.shutdown() + + +def periodic_update(): # pragma: no cover + try: + firewall.update_filters() + except Exception: + LOG.exception('Periodic update of firewall rules failed') + + +def periodic_clean_up(): # pragma: no cover + try: + if node_cache.clean_up(): + firewall.update_filters() + sync_with_ironic() + except Exception: + LOG.exception('Periodic clean up of node cache failed') + + +def sync_with_ironic(): + ironic = ir_utils.get_client() + # TODO(yuikotakada): pagination + ironic_nodes = ironic.node.list(limit=0) + ironic_node_uuids = {node.uuid for node in ironic_nodes} + node_cache.delete_nodes_not_in_list(ironic_node_uuids) diff --git a/setup.cfg b/setup.cfg index 91437ef90..77c5f71d8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,7 @@ packages = [entry_points] console_scripts = - ironic-inspector = ironic_inspector.main:main + ironic-inspector = ironic_inspector.cmd.all:main ironic-inspector-dbsync = ironic_inspector.dbsync:main ironic-inspector-rootwrap = oslo_rootwrap.cmd:main ironic_inspector.hooks.processing =