diff --git a/example.conf b/example.conf index 5b5f5c9fa..3259b73e4 100644 --- a/example.conf +++ b/example.conf @@ -359,3 +359,33 @@ # (boolean value) # Deprecated group/name - [discoverd]/always_store_ramdisk_logs #always_store_ramdisk_logs = false + + +[swift] + +# +# From ironic_inspector.common.swift +# + +# Maximum number of times to retry a Swift request, before failing. +# (integer value) +#max_retries = 2 + +# Number of seconds that the Swift object will last before being +# deleted. (set to 0 to never delete the object). (integer value) +#delete_after = 0 + +# User name for accessing Swift API. (string value) +#username = + +# Password for accessing Swift API. (string value) +#password = + +# Tenant name for accessing Swift API. (string value) +#tenant_name = + +# Keystone authentication API version (string value) +#os_auth_version = 2 + +# Keystone authentication URL (string value) +#os_auth_url = diff --git a/ironic_inspector/common/swift.py b/ironic_inspector/common/swift.py new file mode 100644 index 000000000..69dff11ca --- /dev/null +++ b/ironic_inspector/common/swift.py @@ -0,0 +1,130 @@ +# 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. + +# Mostly copied from ironic/common/swift.py + +import logging + +from oslo_config import cfg +from swiftclient import client as swift_client +from swiftclient import exceptions as swift_exceptions + +from ironic_inspector.common.i18n import _ +from ironic_inspector import utils + +CONF = cfg.CONF + + +LOG = logging.getLogger('ironic_inspector.common.swift') + + +SWIFT_OPTS = [ + cfg.IntOpt('max_retries', + default=2, + help='Maximum number of times to retry a Swift request, ' + 'before failing.'), + cfg.IntOpt('delete_after', + default=0, + help='Number of seconds that the Swift object will last before ' + 'being deleted. (set to 0 to never delete the object).'), + cfg.StrOpt('container', + default='ironic-inspector', + help='Default Swift container to use when creating objects.'), + cfg.StrOpt('username', + default='', + help='User name for accessing Swift API.'), + cfg.StrOpt('password', + default='', + help='Password for accessing Swift API.', + secret=True), + cfg.StrOpt('tenant_name', + default='', + help='Tenant name for accessing Swift API.'), + cfg.StrOpt('os_auth_version', + default='2', + help='Keystone authentication API version'), + cfg.StrOpt('os_auth_url', + default='', + help='Keystone authentication URL'), +] + + +def list_opts(): + return [ + ('swift', SWIFT_OPTS) + ] + +CONF.register_opts(SWIFT_OPTS, group='swift') + + +class SwiftAPI(object): + """API for communicating with Swift.""" + + def __init__(self, + user=CONF.swift.username, + tenant_name=CONF.swift.tenant_name, + key=CONF.swift.password, + auth_url=CONF.swift.os_auth_url, + auth_version=CONF.swift.os_auth_version): + """Constructor for creating a SwiftAPI object. + + :param user: the name of the user for Swift account + :param tenant_name: the name of the tenant for Swift account + :param key: the 'password' or key to authenticate with + :param auth_url: the url for authentication + :param auth_version: the version of api to use for authentication + """ + params = {'retries': CONF.swift.max_retries, + 'user': user, + 'tenant_name': tenant_name, + 'key': key, + 'authurl': auth_url, + 'auth_version': auth_version} + + self.connection = swift_client.Connection(**params) + + def create_object(self, object, data, container=CONF.swift.container, + headers=None): + """Uploads a given string to Swift. + + :param object: The name of the object in Swift + :param data: string data to put in the object + :param container: The name of the container for the object. + :param headers: the headers for the object to pass to Swift + :returns: The Swift UUID of the object + :raises: utils.Error, if any operation with Swift fails. + """ + try: + self.connection.put_container(container) + except swift_exceptions.ClientException as e: + err_msg = (_('Swift failed to create container %(container)s. ' + 'Error was: %(error)s') % + {'container': container, 'error': e}) + raise utils.Error(err_msg) + + if CONF.swift.delete_after > 0: + headers = headers or {} + headers['X-Delete-After'] = CONF.swift.delete_after + + try: + obj_uuid = self.connection.put_object(container, + object, + data, + headers=headers) + except swift_exceptions.ClientException as e: + err_msg = (_('Swift failed to create object %(object)s in ' + 'container %(container)s. Error was: %(error)s') % + {'object': object, 'container': container, 'error': e}) + raise utils.Error(err_msg) + + return obj_uuid diff --git a/ironic_inspector/plugins/edeploy.py b/ironic_inspector/plugins/edeploy.py index 104e3ad1a..b5b242665 100644 --- a/ironic_inspector/plugins/edeploy.py +++ b/ironic_inspector/plugins/edeploy.py @@ -17,27 +17,48 @@ See https://blueprints.launchpad.net/ironic-inspector/+spec/edeploy for details on how to use it. Note that this plugin requires a special ramdisk. """ +import json import logging +from oslo_config import cfg + from ironic_inspector.common.i18n import _LW +from ironic_inspector.common import swift from ironic_inspector.plugins import base +CONF = cfg.CONF + + LOG = logging.getLogger('ironic_inspector.plugins.edeploy') class eDeployHook(base.ProcessingHook): """Processing hook for saving additional data from eDeploy ramdisk.""" + def _store_extra_hardware(self, name, data): + """Handles storing the extra hardware data from the ramdisk""" + swift_api = swift.SwiftAPI() + swift_api.create_object(name, data) + def before_update(self, introspection_data, node_info, node_patches, ports_patches, **kwargs): - """Store the hardware data from what has been discovered.""" + """Stores the 'data' key from introspection_data in Swift. + + If the 'data' key exists, updates Ironic extra column + 'hardware_swift_object' key to the name of the Swift object, and stores + the data in the 'inspector' container in Swift. + + Otherwise, it does nothing. + """ if 'data' not in introspection_data: - LOG.warning(_LW('No eDeploy data was received from the ramdisk')) + LOG.warning(_LW('No extra hardware information was received from ' + 'the ramdisk')) return - # (trown) it is useful for the edeploy report tooling to have the node - # uuid stored with the other edeploy_facts - introspection_data['data'].append(['system', 'product', - 'ironic_uuid', node_info.uuid]) + + name = 'extra_hardware-%s' % node_info.uuid + self._store_extra_hardware(name, + json.dumps(introspection_data['data'])) + node_patches.append({'op': 'add', - 'path': '/extra/edeploy_facts', - 'value': introspection_data['data']}) + 'path': '/extra/hardware_swift_object', + 'value': name}) diff --git a/ironic_inspector/test/test_plugins_edeploy.py b/ironic_inspector/test/test_plugins_edeploy.py index 21d31c9ba..ad1dd0307 100644 --- a/ironic_inspector/test/test_plugins_edeploy.py +++ b/ironic_inspector/test/test_plugins_edeploy.py @@ -11,10 +11,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +try: + from unittest import mock +except ImportError: + import mock + from ironic_inspector.plugins import edeploy from ironic_inspector.test import base as test_base +@mock.patch.object(edeploy.swift, 'SwiftAPI', autospec=True) class TestEdeploy(test_base.NodeTest): def setUp(self): @@ -29,7 +36,7 @@ class TestEdeploy(test_base.NodeTest): self.assertFalse(ports_patches) return node_patches - def test_data_recieved(self): + def test_data_recieved(self, swift_mock): introspection_data = { 'data': [['memory', 'total', 'size', '4294967296'], ['cpu', 'physical', 'number', '1'], @@ -37,19 +44,21 @@ class TestEdeploy(test_base.NodeTest): self.hook.before_processing(introspection_data) node_patches = self._before_update(introspection_data) - expected_value = [['memory', 'total', 'size', '4294967296'], - ['cpu', 'physical', 'number', '1'], - ['cpu', 'logical', 'number', '1'], - ['system', 'product', 'ironic_uuid', self.node.uuid]] + swift_conn = swift_mock.return_value + name = 'extra_hardware-%s' % self.uuid + data = json.dumps(introspection_data['data']) + swift_conn.create_object.assert_called_once_with(name, data) self.assertEqual('add', node_patches[0]['op']) - self.assertEqual('/extra/edeploy_facts', + self.assertEqual('/extra/hardware_swift_object', node_patches[0]['path']) - self.assertEqual(expected_value, + self.assertEqual(name, node_patches[0]['value']) - def test_no_data_recieved(self): + def test_no_data_recieved(self, swift_mock): introspection_data = {'cats': 'meow'} + swift_conn = swift_mock.return_value self.hook.before_processing(introspection_data) node_patches = self._before_update(introspection_data) self.assertFalse(node_patches) + self.assertFalse(swift_conn.create_object.called) diff --git a/ironic_inspector/test/test_swift.py b/ironic_inspector/test/test_swift.py new file mode 100644 index 000000000..e3864563e --- /dev/null +++ b/ironic_inspector/test/test_swift.py @@ -0,0 +1,97 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# +# 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. + +# Mostly copied from ironic/tests/test_swift.py + +import sys + +try: + from unittest import mock +except ImportError: + import mock +from oslo_config import cfg +from six.moves import reload_module +from swiftclient import client as swift_client +from swiftclient import exceptions as swift_exception + +from ironic_inspector.common import swift +from ironic_inspector.test import base +from ironic_inspector import utils + +CONF = cfg.CONF + + +@mock.patch.object(swift_client, 'Connection', autospec=True) +class SwiftTestCase(base.BaseTest): + + def setUp(self): + super(SwiftTestCase, self).setUp() + self.swift_exception = swift_exception.ClientException('', '') + + CONF.set_override('username', 'swift', 'swift') + CONF.set_override('tenant_name', 'tenant', 'swift') + CONF.set_override('password', 'password', 'swift') + CONF.set_override('os_auth_url', 'http://authurl/v2.0', 'swift') + CONF.set_override('os_auth_version', '2', 'swift') + CONF.set_override('max_retries', 2, 'swift') + + # The constructor of SwiftAPI accepts arguments whose + # default values are values of some config options above. So reload + # the module to make sure the required values are set. + reload_module(sys.modules['ironic_inspector.common.swift']) + + def test___init__(self, connection_mock): + swift.SwiftAPI() + params = {'retries': 2, + 'user': 'swift', + 'tenant_name': 'tenant', + 'key': 'password', + 'authurl': 'http://authurl/v2.0', + 'auth_version': '2'} + connection_mock.assert_called_once_with(**params) + + def test_create_object(self, connection_mock): + swiftapi = swift.SwiftAPI() + connection_obj_mock = connection_mock.return_value + + connection_obj_mock.put_object.return_value = 'object-uuid' + + object_uuid = swiftapi.create_object('object', 'some-string-data') + + connection_obj_mock.put_container.assert_called_once_with('ironic-' + 'inspector') + connection_obj_mock.put_object.assert_called_once_with( + 'ironic-inspector', 'object', 'some-string-data', headers=None) + self.assertEqual('object-uuid', object_uuid) + + def test_create_object_create_container_fails(self, connection_mock): + swiftapi = swift.SwiftAPI() + connection_obj_mock = connection_mock.return_value + connection_obj_mock.put_container.side_effect = self.swift_exception + self.assertRaises(utils.Error, swiftapi.create_object, 'object', + 'some-string-data') + connection_obj_mock.put_container.assert_called_once_with('ironic-' + 'inspector') + self.assertFalse(connection_obj_mock.put_object.called) + + def test_create_object_put_object_fails(self, connection_mock): + swiftapi = swift.SwiftAPI() + connection_obj_mock = connection_mock.return_value + connection_obj_mock.put_object.side_effect = self.swift_exception + self.assertRaises(utils.Error, swiftapi.create_object, 'object', + 'some-string-data') + connection_obj_mock.put_container.assert_called_once_with('ironic-' + 'inspector') + connection_obj_mock.put_object.assert_called_once_with( + 'ironic-inspector', 'object', 'some-string-data', headers=None) diff --git a/plugin-requirements.txt b/plugin-requirements.txt index e69de29bb..576ef77ea 100644 --- a/plugin-requirements.txt +++ b/plugin-requirements.txt @@ -0,0 +1,2 @@ +# required for edeploy plugin +python-swiftclient>=2.2.0 diff --git a/setup.py b/setup.py index 074db1c9b..f42ca1dab 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ setup( ], 'oslo.config.opts': [ "ironic_inspector = ironic_inspector.conf:list_opts", + "ironic_inspector.common.swift = ironic_inspector.common.swift:list_opts" ], }, classifiers = [ diff --git a/tox.ini b/tox.ini index ff44b5db5..4dce8900a 100644 --- a/tox.ini +++ b/tox.ini @@ -44,3 +44,4 @@ commands = --output-file example.conf \ --namespace ironic_inspector \ --namespace keystonemiddleware.auth_token + --namespace ironic_inspector.common.swift