diff --git a/nova/tests/virt/ironic/__init__.py b/nova/tests/virt/ironic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/tests/virt/ironic/test_client_wrapper.py b/nova/tests/virt/ironic/test_client_wrapper.py new file mode 100644 index 0000000000..851b8a150b --- /dev/null +++ b/nova/tests/virt/ironic/test_client_wrapper.py @@ -0,0 +1,124 @@ +# Copyright 2014 Red Hat, Inc. +# All Rights Reserved. +# +# 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 ironicclient import client as ironic_client +from ironicclient import exc as ironic_exception +import mock +from oslo.config import cfg + +from nova import exception +from nova import test +from nova.tests.virt.ironic import utils as ironic_utils +from nova.virt.ironic import client_wrapper + +CONF = cfg.CONF + +FAKE_CLIENT = ironic_utils.FakeClient() + + +class IronicClientWrapperTestCase(test.NoDBTestCase): + + def setUp(self): + super(IronicClientWrapperTestCase, self).setUp() + self.icli = client_wrapper.IronicClientWrapper() + # Do not waste time sleeping + cfg.CONF.set_override('api_retry_interval', 0, 'ironic') + + @mock.patch.object(client_wrapper.IronicClientWrapper, '_multi_getattr') + @mock.patch.object(client_wrapper.IronicClientWrapper, '_get_client') + def test_call_good_no_args(self, mock_get_client, mock_multi_getattr): + mock_get_client.return_value = FAKE_CLIENT + self.icli.call("node.list") + mock_get_client.assert_called_once_with() + mock_multi_getattr.assert_called_once_with(FAKE_CLIENT, "node.list") + mock_multi_getattr.return_value.assert_called_once_with() + + @mock.patch.object(client_wrapper.IronicClientWrapper, '_multi_getattr') + @mock.patch.object(client_wrapper.IronicClientWrapper, '_get_client') + def test_call_good_with_args(self, mock_get_client, mock_multi_getattr): + mock_get_client.return_value = FAKE_CLIENT + self.icli.call("node.list", 'test', associated=True) + mock_get_client.assert_called_once_with() + mock_multi_getattr.assert_called_once_with(FAKE_CLIENT, "node.list") + mock_multi_getattr.return_value.assert_called_once_with( + 'test', associated=True) + + @mock.patch.object(ironic_client, 'get_client') + def test__get_client_no_auth_token(self, mock_ir_cli): + self.flags(admin_auth_token=None, group='ironic') + icli = client_wrapper.IronicClientWrapper() + # dummy call to have _get_client() called + icli.call("node.list") + expected = {'os_username': CONF.ironic.admin_username, + 'os_password': CONF.ironic.admin_password, + 'os_auth_url': CONF.ironic.admin_url, + 'os_tenant_name': CONF.ironic.admin_tenant_name, + 'os_service_type': 'baremetal', + 'os_endpoint_type': 'public', + 'ironic_url': CONF.ironic.api_endpoint} + mock_ir_cli.assert_called_once_with(CONF.ironic.api_version, + **expected) + + @mock.patch.object(ironic_client, 'get_client') + def test__get_client_with_auth_token(self, mock_ir_cli): + self.flags(admin_auth_token='fake-token', group='ironic') + icli = client_wrapper.IronicClientWrapper() + # dummy call to have _get_client() called + icli.call("node.list") + expected = {'os_auth_token': 'fake-token', + 'ironic_url': CONF.ironic.api_endpoint} + mock_ir_cli.assert_called_once_with(CONF.ironic.api_version, + **expected) + + @mock.patch.object(client_wrapper.IronicClientWrapper, '_multi_getattr') + @mock.patch.object(client_wrapper.IronicClientWrapper, '_get_client') + def test_call_fail(self, mock_get_client, mock_multi_getattr): + cfg.CONF.set_override('api_max_retries', 2, 'ironic') + test_obj = mock.Mock() + test_obj.side_effect = ironic_exception.HTTPServiceUnavailable + mock_multi_getattr.return_value = test_obj + mock_get_client.return_value = FAKE_CLIENT + self.assertRaises(exception.NovaException, self.icli.call, "node.list") + self.assertEqual(2, test_obj.call_count) + + @mock.patch.object(client_wrapper.IronicClientWrapper, '_multi_getattr') + @mock.patch.object(client_wrapper.IronicClientWrapper, '_get_client') + def test_call_fail_unexpected_exception(self, mock_get_client, + mock_multi_getattr): + test_obj = mock.Mock() + test_obj.side_effect = ironic_exception.HTTPNotFound + mock_multi_getattr.return_value = test_obj + mock_get_client.return_value = FAKE_CLIENT + self.assertRaises(ironic_exception.HTTPNotFound, self.icli.call, + "node.list") + + @mock.patch.object(ironic_client, 'get_client') + def test__get_client_unauthorized(self, mock_get_client): + mock_get_client.side_effect = ironic_exception.Unauthorized + self.assertRaises(exception.NovaException, self.icli._get_client) + + @mock.patch.object(ironic_client, 'get_client') + def test__get_client_unexpected_exception(self, mock_get_client): + mock_get_client.side_effect = ironic_exception.ConnectionRefused + self.assertRaises(ironic_exception.ConnectionRefused, + self.icli._get_client) + + def test__multi_getattr_good(self): + response = self.icli._multi_getattr(FAKE_CLIENT, "node.list") + self.assertEqual(FAKE_CLIENT.node.list, response) + + def test__multi_getattr_fail(self): + self.assertRaises(AttributeError, self.icli._multi_getattr, + FAKE_CLIENT, "nonexistent") diff --git a/nova/tests/virt/ironic/utils.py b/nova/tests/virt/ironic/utils.py new file mode 100644 index 0000000000..8d747b2ae8 --- /dev/null +++ b/nova/tests/virt/ironic/utils.py @@ -0,0 +1,115 @@ +# Copyright 2014 Red Hat, Inc. +# All Rights Reserved. +# +# 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 nova.virt.ironic import ironic_states + + +def get_test_validation(**kw): + return type('interfaces', (object,), + {'power': kw.get('power', True), + 'deploy': kw.get('deploy', True), + 'console': kw.get('console', True), + 'rescue': kw.get('rescue', True)})() + + +def get_test_node(**kw): + return type('node', (object,), + {'uuid': kw.get('uuid', 'eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa'), + 'chassis_uuid': kw.get('chassis_uuid'), + 'power_state': kw.get('power_state', + ironic_states.NOSTATE), + 'target_power_state': kw.get('target_power_state', + ironic_states.NOSTATE), + 'provision_state': kw.get('provision_state', + ironic_states.NOSTATE), + 'target_provision_state': kw.get('target_provision_state', + ironic_states.NOSTATE), + 'last_error': kw.get('last_error'), + 'instance_uuid': kw.get('instance_uuid'), + 'driver': kw.get('driver', 'fake'), + 'driver_info': kw.get('driver_info', {}), + 'properties': kw.get('properties', {}), + 'reservation': kw.get('reservation'), + 'maintenance': kw.get('maintenance', False), + 'extra': kw.get('extra', {}), + 'updated_at': kw.get('created_at'), + 'created_at': kw.get('updated_at')})() + + +def get_test_port(**kw): + return type('port', (object,), + {'uuid': kw.get('uuid', 'gggggggg-uuuu-qqqq-ffff-llllllllllll'), + 'node_uuid': kw.get('node_uuid', get_test_node().uuid), + 'address': kw.get('address', 'FF:FF:FF:FF:FF:FF'), + 'extra': kw.get('extra', {}), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at')})() + + +def get_test_flavor(**kw): + default_extra_specs = {'baremetal:deploy_kernel_id': + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'baremetal:deploy_ramdisk_id': + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'} + return {'name': kw.get('name', 'fake.flavor'), + 'extra_specs': kw.get('extra_specs', default_extra_specs), + 'swap': kw.get('swap', 0), + 'ephemeral_gb': kw.get('ephemeral_gb', 0)} + + +def get_test_image_meta(**kw): + return {'id': kw.get('id', 'cccccccc-cccc-cccc-cccc-cccccccccccc')} + + +class FakePortClient(object): + + def get(self, port_uuid): + pass + + def update(self, port_uuid, patch): + pass + + +class FakeNodeClient(object): + + def list(self): + return [] + + def get(self, node_uuid): + pass + + def get_by_instance_uuid(self, instance_uuid): + pass + + def list_ports(self, node_uuid): + pass + + def set_power_state(self, node_uuid, target): + pass + + def set_provision_state(self, node_uuid, target): + pass + + def update(self, node_uuid, patch): + pass + + def validate(self, node_uuid): + pass + + +class FakeClient(object): + + node = FakeNodeClient() + port = FakePortClient() diff --git a/nova/virt/ironic/__init__.py b/nova/virt/ironic/__init__.py index e69de29bb2..e37d32256e 100644 --- a/nova/virt/ironic/__init__.py +++ b/nova/virt/ironic/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 nova.virt.ironic import driver + +IronicDriver = driver.IronicDriver diff --git a/nova/virt/ironic/client_wrapper.py b/nova/virt/ironic/client_wrapper.py new file mode 100644 index 0000000000..9a074e897b --- /dev/null +++ b/nova/virt/ironic/client_wrapper.py @@ -0,0 +1,104 @@ +# coding=utf-8 +# +# Copyright 2014 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 time + +from ironicclient import client as ironic_client +from ironicclient import exc as ironic_exception +from oslo.config import cfg + +from nova import exception +from nova.openstack.common import gettextutils +from nova.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +_ = gettextutils._ + + +class IronicClientWrapper(object): + """Ironic client wrapper class that encapsulates retry logic.""" + + def _get_client(self): + # TODO(deva): save and reuse existing client & auth token + # until it expires or is no longer valid + auth_token = CONF.ironic.admin_auth_token + if auth_token is None: + kwargs = {'os_username': CONF.ironic.admin_username, + 'os_password': CONF.ironic.admin_password, + 'os_auth_url': CONF.ironic.admin_url, + 'os_tenant_name': CONF.ironic.admin_tenant_name, + 'os_service_type': 'baremetal', + 'os_endpoint_type': 'public', + 'ironic_url': CONF.ironic.api_endpoint} + else: + kwargs = {'os_auth_token': auth_token, + 'ironic_url': CONF.ironic.api_endpoint} + + try: + cli = ironic_client.get_client(CONF.ironic.api_version, **kwargs) + except ironic_exception.Unauthorized: + msg = (_("Unable to authenticate Ironic client.")) + LOG.error(msg) + raise exception.NovaException(msg) + + return cli + + def _multi_getattr(self, obj, attr): + """Support nested attribute path for getattr(). + + :param obj: Root object. + :param attr: Path of final attribute to get. E.g., "a.b.c.d" + + :returns: The value of the final named attribute. + :raises: AttributeError will be raised if the path is invalid. + """ + for attribute in attr.split("."): + obj = getattr(obj, attribute) + return obj + + def call(self, method, *args, **kwargs): + """Call an Ironic client method and retry on errors. + + :param method: Name of the client method to call as a string. + :param args: Client method arguments. + :param kwargs: Client method keyword arguments. + + :raises: NovaException if all retries failed. + """ + retry_excs = (ironic_exception.ServiceUnavailable, + ironic_exception.ConnectionRefused, + ironic_exception.Conflict) + num_attempts = CONF.ironic.api_max_retries + + for attempt in range(1, num_attempts + 1): + client = self._get_client() + try: + return self._multi_getattr(client, method)(*args, **kwargs) + except retry_excs: + msg = (_("Error contacting Ironic server for '%(method)s'. " + "Attempt %(attempt)d of %(total)d") + % {'method': method, + 'attempt': attempt, + 'total': num_attempts}) + if attempt == num_attempts: + LOG.error(msg) + raise exception.NovaException(msg) + LOG.warning(msg) + time.sleep(CONF.ironic.api_retry_interval) diff --git a/nova/virt/ironic/driver.py b/nova/virt/ironic/driver.py new file mode 100644 index 0000000000..1779f1e95f --- /dev/null +++ b/nova/virt/ironic/driver.py @@ -0,0 +1,68 @@ +# coding=utf-8 +# +# Copyright 2014 Red Hat, Inc. +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. + +""" +A driver wrapping the Ironic API, such that Nova may provision +bare metal resources. +""" + +from oslo.config import cfg + +from nova.virt import driver as virt_driver + + +opts = [ + cfg.IntOpt('api_version', + default=1, + help='Version of Ironic API service endpoint.'), + cfg.StrOpt('api_endpoint', + help='URL for Ironic API endpoint.'), + cfg.StrOpt('admin_username', + help='Ironic keystone admin name'), + cfg.StrOpt('admin_password', + help='Ironic keystone admin password.'), + cfg.StrOpt('admin_auth_token', + help='Ironic keystone auth token.'), + cfg.StrOpt('admin_url', + help='Keystone public API endpoint.'), + cfg.StrOpt('client_log_level', + help='Log level override for ironicclient. Set this in ' + 'order to override the global "default_log_levels", ' + '"verbose", and "debug" settings.'), + cfg.StrOpt('admin_tenant_name', + help='Ironic keystone tenant name.'), + cfg.IntOpt('api_max_retries', + default=60, + help=('How many retries when a request does conflict.')), + cfg.IntOpt('api_retry_interval', + default=2, + help=('How often to retry in seconds when a request ' + 'does conflict')), + ] + +ironic_group = cfg.OptGroup(name='ironic', + title='Ironic Options') + +CONF = cfg.CONF +CONF.register_group(ironic_group) +CONF.register_opts(opts, ironic_group) + + +class IronicDriver(virt_driver.ComputeDriver): + """Hypervisor driver for Ironic - bare metal provisioning.""" + pass diff --git a/nova/virt/ironic/ironic_states.py b/nova/virt/ironic/ironic_states.py new file mode 100644 index 0000000000..36518f2ae7 --- /dev/null +++ b/nova/virt/ironic/ironic_states.py @@ -0,0 +1,66 @@ +# Copyright (c) 2012 NTT DOCOMO, INC. +# Copyright 2010 OpenStack Foundation +# All Rights Reserved. +# +# 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. + +""" +Mapping of bare metal node states. + +A node may have empty {} `properties` and `driver_info` in which case, it is +said to be "initialized" but "not available", and the state is NOSTATE. + +When updating `properties`, any data will be rejected if the data fails to be +validated by the driver. Any node with non-empty `properties` is said to be +"initialized", and the state is INIT. + +When the driver has received both `properties` and `driver_info`, it will check +the power status of the node and update the `power_state` accordingly. If the +driver fails to read the power state from the node, it will reject the +`driver_info` change, and the state will remain as INIT. If the power status +check succeeds, `power_state` will change to one of POWER_ON or POWER_OFF, +accordingly. + +At this point, the power state may be changed via the API, a console +may be started, and a tenant may be associated. + +The `power_state` for a node always represents the current power state. Any +power operation sets this to the actual state when done (whether successful or +not). It is set to ERROR only when unable to get the power state from a node. + +When `instance_uuid` is set to a non-empty / non-None value, the node is said +to be "associated" with a tenant. + +An associated node can not be deleted. + +The `instance_uuid` field may be unset only if the node is in POWER_OFF or +ERROR states. +""" + +NOSTATE = None +INIT = 'initializing' +ACTIVE = 'active' +BUILDING = 'building' +DEPLOYWAIT = 'wait call-back' +DEPLOYING = 'deploying' +DEPLOYFAIL = 'deploy failed' +DEPLOYDONE = 'deploy complete' +DELETING = 'deleting' +DELETED = 'deleted' +ERROR = 'error' +REBUILD = 'rebuild' + +POWER_ON = 'power on' +POWER_OFF = 'power off' +REBOOT = 'rebooting' +SUSPEND = 'suspended' diff --git a/test-requirements.txt b/test-requirements.txt index e1b09ecb98..839739d5b3 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -13,6 +13,7 @@ mox>=0.5.3 MySQL-python psycopg2 pylint==0.25.2 +python-ironicclient>=0.2.1 python-subunit>=0.0.18 sphinx>=1.1.2,!=1.2.0,<1.3 oslosphinx>=2.2.0.0a2