diff --git a/HTTP-API.rst b/HTTP-API.rst index 794ad1ad6..ca7dac2c8 100644 --- a/HTTP-API.rst +++ b/HTTP-API.rst @@ -76,6 +76,8 @@ the ramdisk. Request body: JSON dictionary with at least these keys: * ``error`` optional error happened during ramdisk run, interpreted by ``ramdisk_error`` plugin +* ``logs`` optional base64-encoded logs from the ramdisk + * ``block_devices`` optional block devices information for ``root_device_hint`` plugin, dictionary with keys: diff --git a/README.rst b/README.rst index 61c4fde14..6791c2d42 100644 --- a/README.rst +++ b/README.rst @@ -294,7 +294,8 @@ These are plugins that are enabled by default and should not be disabled, unless you understand what you're doing: ``ramdisk_error`` - reports error, if ``error`` field is set by the ramdisk. + reports error, if ``error`` field is set by the ramdisk, also optionally + stores logs from ``logs`` field, see `HTTP API`_ for details. ``scheduler`` validates and updates basic hardware scheduling properties: CPU number and architecture, memory and disk size. diff --git a/ironic_discoverd/conf.py b/ironic_discoverd/conf.py index dd1bc0d63..9360c4b63 100644 --- a/ironic_discoverd/conf.py +++ b/ironic_discoverd/conf.py @@ -120,6 +120,9 @@ SERVICE_OPTS = [ cfg.BoolOpt('debug', default=False, help='Debug mode enabled/disabled.'), + cfg.StrOpt('ramdisk_logs_dir', + help='If set, logs from ramdisk will be stored in this ' + 'directory'), cfg.BoolOpt('ports_for_inactive_interfaces', default=False, help='DEPRECATED: use add_ports.'), diff --git a/ironic_discoverd/plugins/standard.py b/ironic_discoverd/plugins/standard.py index d9fa5559e..8c65eba45 100644 --- a/ironic_discoverd/plugins/standard.py +++ b/ironic_discoverd/plugins/standard.py @@ -13,7 +13,10 @@ """Standard set of plugins.""" +import base64 +import datetime import logging +import os import sys from oslo_config import cfg @@ -160,8 +163,31 @@ class ValidateInterfacesHook(base.ProcessingHook): class RamdiskErrorHook(base.ProcessingHook): """Hook to process error send from the ramdisk.""" + DATETIME_FORMAT = '%Y.%m.%d_%H.%M.%S_%f' + def before_processing(self, node_info): if not node_info.get('error'): return + logs = node_info.get('logs') + if logs: + self._store_logs(logs, node_info) + raise utils.Error(_('Ramdisk reported error: %s') % node_info['error']) + + def _store_logs(self, logs, node_info): + if not CONF.discoverd.ramdisk_logs_dir: + LOG.warn(_LW('Failed to store logs received from the discovery ' + 'ramdisk because ramdisk_logs_dir configuration ' + 'option is not set')) + return + + if not os.path.exists(CONF.discoverd.ramdisk_logs_dir): + os.makedirs(CONF.discoverd.ramdisk_logs_dir) + + time_fmt = datetime.datetime.utcnow().strftime(self.DATETIME_FORMAT) + bmc_address = node_info.get('ipmi_address', 'unknown') + file_name = 'bmc_%s_%s' % (bmc_address, time_fmt) + with open(os.path.join(CONF.discoverd.ramdisk_logs_dir, file_name), + 'wb') as fp: + fp.write(base64.b64decode(logs)) diff --git a/ironic_discoverd/test/test_plugins_standard.py b/ironic_discoverd/test/test_plugins_standard.py new file mode 100644 index 000000000..5f4a43e65 --- /dev/null +++ b/ironic_discoverd/test/test_plugins_standard.py @@ -0,0 +1,83 @@ +# 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 base64 +import os +import shutil +import tempfile + +from oslo_config import cfg + +from ironic_discoverd import process +from ironic_discoverd.test import base as test_base +from ironic_discoverd import utils + +CONF = cfg.CONF + + +class TestRamdiskError(test_base.BaseTest): + def setUp(self): + super(TestRamdiskError, self).setUp() + self.msg = 'BOOM' + self.bmc_address = '1.2.3.4' + self.data = { + 'error': self.msg, + 'ipmi_address': self.bmc_address, + } + + self.tempdir = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(self.tempdir)) + CONF.set_override('ramdisk_logs_dir', self.tempdir, 'discoverd') + + def test_no_logs(self): + self.assertRaisesRegexp(utils.Error, + self.msg, + process.process, self.data) + self.assertEqual([], os.listdir(self.tempdir)) + + def test_logs_disabled(self): + self.data['logs'] = 'some log' + CONF.set_override('ramdisk_logs_dir', None, 'discoverd') + + self.assertRaisesRegexp(utils.Error, + self.msg, + process.process, self.data) + self.assertEqual([], os.listdir(self.tempdir)) + + def test_logs(self): + log = b'log contents' + self.data['logs'] = base64.b64encode(log) + + self.assertRaisesRegexp(utils.Error, + self.msg, + process.process, self.data) + + files = os.listdir(self.tempdir) + self.assertEqual(1, len(files)) + filename = files[0] + self.assertTrue(filename.startswith('bmc_%s_' % self.bmc_address), + '%s does not start with bmc_%s' + % (filename, self.bmc_address)) + with open(os.path.join(self.tempdir, filename), 'rb') as fp: + self.assertEqual(log, fp.read()) + + def test_logs_create_dir(self): + shutil.rmtree(self.tempdir) + self.data['logs'] = base64.b64encode(b'log') + + self.assertRaisesRegexp(utils.Error, + self.msg, + process.process, self.data) + + files = os.listdir(self.tempdir) + self.assertEqual(1, len(files)) diff --git a/ironic_discoverd/test/test_process.py b/ironic_discoverd/test/test_process.py index 55e3e0a1f..1b44658f1 100644 --- a/ironic_discoverd/test/test_process.py +++ b/ironic_discoverd/test/test_process.py @@ -241,15 +241,6 @@ class TestProcess(BaseTest): process_mock.assert_called_once_with(cli, cli.node.get.return_value, self.data, pop_mock.return_value) - @prepare_mocks - def test_error(self, cli, pop_mock, process_mock): - self.data['error'] = 'BOOM' - - self.assertRaisesRegexp(utils.Error, - 'BOOM', - process.process, self.data) - self.assertFalse(process_mock.called) - @prepare_mocks def test_missing_required(self, cli, pop_mock, process_mock): del self.data['cpus']