diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 0d54a145c3..afcc3416bb 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -227,6 +227,32 @@ # "ironic.hardware.interfaces.raid" entrypoint. (string value) #default_raid_interface = +# Specify the list of rescue interfaces to load during service +# initialization. Missing rescue interfaces, or rescue +# interfaces which fail to initialize, will prevent the +# ironic-conductor service from starting. At least one rescue +# interface that is supported by each enabled hardware type +# must be enabled here, or the ironic-conductor service will +# not start. Must not be an empty list. The default value is a +# recommended set of production-oriented rescue interfaces. A +# complete list of rescue interfaces present on your system +# may be found by enumerating the +# "ironic.hardware.interfaces.rescue" entrypoint. When setting +# this value, please make sure that every enabled hardware +# type will have the same set of enabled rescue interfaces on +# every ironic-conductor service. This option is part of +# rescue feature work, which is not currently exposed to +# users. (list value) +#enabled_rescue_interfaces = no-rescue + +# Default rescue interface to be used for nodes that do not +# have rescue_interface field set. A complete list of rescue +# interfaces present on your system may be found by +# enumerating the "ironic.hardware.interfaces.rescue" +# entrypoint. This option is part of rescue feature work, +# which is not currently exposed to users. (string value) +#default_rescue_interface = + # Specify the list of storage interfaces to load during # service initialization. Missing storage interfaces, or # storage interfaces which fail to initialize, will prevent diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 031c6a8fab..55c6856fb3 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -111,7 +111,7 @@ RELEASE_MAPPING = { 'api': '1.36', 'rpc': '1.42', 'objects': { - 'Node': ['1.21'], + 'Node': ['1.22'], 'Conductor': ['1.2'], 'Chassis': ['1.3'], 'Port': ['1.7'], diff --git a/ironic/common/states.py b/ironic/common/states.py index e270e78313..ddd767db08 100644 --- a/ironic/common/states.py +++ b/ironic/common/states.py @@ -185,6 +185,28 @@ potentially due to invalid or incompatible information being defined for the node. """ +RESCUE = 'rescue' +""" Node is in rescue mode. """ + +RESCUEFAIL = 'rescue failed' +""" Node rescue failed. """ + +RESCUEWAIT = 'rescue wait' +""" Node is waiting on an external callback. + +This will be the node `provision_state` while the node is waiting for +the driver to finish rescuing the node. +""" + +RESCUING = 'rescuing' +""" Node is in process of being rescued. """ + +UNRESCUEFAIL = 'unrescue failed' +""" Node unrescue failed. """ + +UNRESCUING = 'unrescuing' +""" Node is being restored from rescue mode (to active state). """ + UPDATE_ALLOWED_STATES = (DEPLOYFAIL, INSPECTING, INSPECTFAIL, CLEANFAIL, ERROR, VERIFYING, ADOPTFAIL) """Transitional states in which we allow updating a node.""" diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index cae6322294..0f8c37f5ce 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -1555,6 +1555,11 @@ class ConductorManager(base_manager.BaseConductorManager): task.node.instance_info) task.node.driver_internal_info['is_whole_disk_image'] = iwdi for iface_name in task.driver.non_vendor_interfaces: + # TODO(stendulker): Remove this check in 'rescue' API patch + # Do not have to return the validation result for 'rescue' + # interface. + if iface_name == 'rescue': + continue iface = getattr(task.driver, iface_name, None) result = reason = None if iface: diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 58cd7753aa..5736220284 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -52,6 +52,18 @@ _DEFAULT_IFACE_HELP = _('Default {0} interface to be used for nodes that ' 'be found by enumerating the ' '"ironic.hardware.interfaces.{0}" entrypoint.') +# TODO(stendulker) Remove this in rescue API patch. +_ENABLED_IFACE_HELP_FOR_RESCUE = (_ENABLED_IFACE_HELP + + _(' This option is part of rescue feature ' + 'work, which is not currently exposed to ' + 'users.')) + +# TODO(stendulker) Remove this in rescue API patch. +_DEFAULT_IFACE_HELP_FOR_RESCUE = (_DEFAULT_IFACE_HELP + + _(' This option is part of rescue feature ' + 'work, which is not currently exposed to ' + 'users.')) + api_opts = [ cfg.StrOpt( 'auth_strategy', @@ -137,6 +149,11 @@ driver_opts = [ help=_ENABLED_IFACE_HELP.format('raid')), cfg.StrOpt('default_raid_interface', help=_DEFAULT_IFACE_HELP.format('raid')), + cfg.ListOpt('enabled_rescue_interfaces', + default=['no-rescue'], + help=_ENABLED_IFACE_HELP_FOR_RESCUE.format('rescue')), + cfg.StrOpt('default_rescue_interface', + help=_DEFAULT_IFACE_HELP_FOR_RESCUE.format('rescue')), cfg.ListOpt('enabled_storage_interfaces', default=['cinder', 'noop'], help=_ENABLED_IFACE_HELP.format('storage')), diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index 6231831b94..1f1e4d2b93 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -81,9 +81,6 @@ class BaseDriver(object): """ rescue = None - # NOTE(deva): hide rescue from the interface list in Icehouse - # because the API for this has not been created yet. - # standard_interfaces.append('rescue') """`Standard` attribute for accessing rescue features. A reference to an instance of :class:RescueInterface. @@ -170,7 +167,9 @@ class BareDriver(BaseDriver): A reference to an instance of :class:StorageInterface. """ - standard_interfaces = BaseDriver.standard_interfaces + ('storage',) + + standard_interfaces = (BaseDriver.standard_interfaces + ('rescue', + 'storage',)) ALL_INTERFACES = set(BareDriver().all_interfaces) @@ -562,6 +561,10 @@ class RescueInterface(BaseInterface): """Boot the task's node into a rescue environment. :param task: a TaskManager instance containing the node to act on. + :raises: InstanceRescueFailure if node validation or rescue operation + fails. + :returns: states.RESCUEWAIT if rescue is in progress asynchronously + or states.RESCUE if it is complete. """ @abc.abstractmethod @@ -569,8 +572,22 @@ class RescueInterface(BaseInterface): """Tear down the rescue environment, and return to normal. :param task: a TaskManager instance containing the node to act on. + :raises: InstanceUnrescueFailure if node validation or unrescue + operation fails. + :returns: states.ACTIVE if it is successful. """ + def clean_up(self, task): + """Clean up the rescue environment for the task's node. + + This is particularly useful for nodes where rescuing is asynchronous + and a timeout occurs. + + :param task: a TaskManager instance containing the node to act on. + :returns: None + """ + pass + # Representation of a single vendor method metadata VendorMetadata = collections.namedtuple('VendorMetadata', ['method', diff --git a/ironic/drivers/fake_hardware.py b/ironic/drivers/fake_hardware.py index f6320de0cf..6cf6f0d560 100644 --- a/ironic/drivers/fake_hardware.py +++ b/ironic/drivers/fake_hardware.py @@ -66,6 +66,11 @@ class FakeHardware(hardware_type.AbstractHardwareType): """List of classes of supported raid interfaces.""" return [fake.FakeRAID] + @property + def supported_rescue_interfaces(self): + """List of classes of supported rescue interfaces.""" + return [fake.FakeRescue] + @property def supported_storage_interfaces(self): """List of classes of supported storage interfaces.""" diff --git a/ironic/drivers/hardware_type.py b/ironic/drivers/hardware_type.py index 2d0713a589..3011019533 100644 --- a/ironic/drivers/hardware_type.py +++ b/ironic/drivers/hardware_type.py @@ -83,6 +83,11 @@ class AbstractHardwareType(object): """List of supported raid interfaces.""" return [noop.NoRAID] + @property + def supported_rescue_interfaces(self): + """List of supported rescue interfaces.""" + return [noop.NoRescue] + @property def supported_storage_interfaces(self): """List of supported storage interfaces.""" diff --git a/ironic/drivers/modules/fake.py b/ironic/drivers/modules/fake.py index 4ab7772eff..f60c552587 100644 --- a/ironic/drivers/modules/fake.py +++ b/ironic/drivers/modules/fake.py @@ -260,3 +260,19 @@ class FakeStorage(base.StorageInterface): def should_write_image(self, task): return True + + +class FakeRescue(base.RescueInterface): + """Example implementation of a simple rescue interface.""" + + def get_properties(self): + return {} + + def validate(self, task): + pass + + def rescue(self, task): + return states.RESCUE + + def unrescue(self, task): + return states.ACTIVE diff --git a/ironic/objects/node.py b/ironic/objects/node.py index cffd53d9b8..b53efcbbe7 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -15,6 +15,7 @@ from oslo_utils import strutils from oslo_utils import uuidutils +from oslo_utils import versionutils from oslo_versionedobjects import base as object_base from ironic.common import exception @@ -55,7 +56,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # power_interface, raid_interface, vendor_interface # Version 1.20: Type of network_interface changed to just nullable string # Version 1.21: Add storage_interface field - VERSION = '1.21' + # Version 1.22: Add rescue_interface field + VERSION = '1.22' dbapi = db_api.get_instance() @@ -123,6 +125,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'network_interface': object_fields.StringField(nullable=True), 'power_interface': object_fields.StringField(nullable=True), 'raid_interface': object_fields.StringField(nullable=True), + 'rescue_interface': object_fields.StringField(nullable=True), 'storage_interface': object_fields.StringField(nullable=True), 'vendor_interface': object_fields.StringField(nullable=True), } @@ -415,6 +418,41 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): node = cls._from_db_object(context, cls(), db_node) return node + def _convert_to_version(self, target_version, + remove_unavailable_fields=True): + """Convert to the target version. + + Convert the object to the target version. The target version may be + the same, older, or newer than the version of the object. This is + used for DB interactions as well as for serialization/deserialization. + + Version 1.22: rescue_interface field was added. Its default value is + None. For versions prior to this, it should be set to None (or + removed). + + :param target_version: the desired version of the object + :param remove_unavailable_fields: True to remove fields that are + unavailable in the target version; set this to True when + (de)serializing. False to set the unavailable fields to appropriate + values; set this to False for DB interactions. + """ + target_version = versionutils.convert_version_to_tuple(target_version) + # Convert the rescue_interface field. + rescue_iface_is_set = self.obj_attr_is_set('rescue_interface') + if target_version >= (1, 22): + # Target version supports rescue_interface. + if not rescue_iface_is_set: + # Set it to its default value if it is not set. + self.rescue_interface = None + elif rescue_iface_is_set: + # Target version does not support rescue_interface, and it is set. + if remove_unavailable_fields: + # (De)serialising: remove unavailable fields. + delattr(self, 'rescue_interface') + elif self.rescue_interface is not None: + # DB: set unavailable field to the default of None. + self.rescue_interface = None + @base.IronicObjectRegistry.register class NodePayload(notification.NotificationPayloadBase): @@ -461,6 +499,11 @@ class NodePayload(notification.NotificationPayloadBase): 'uuid': ('node', 'uuid') } + # TODO(stendulker): At a later point in time, once rescue_interface + # is able to be leveraged, we need to add the rescue_interface + # field to payload and increment the object versions for all objects + # that inherit the NodePayload object. + # Version 1.0: Initial version, based off of Node version 1.18. # Version 1.1: Type of network_interface changed to just nullable string # similar to version 1.20 of Node. diff --git a/ironic/tests/unit/api/controllers/v1/test_driver.py b/ironic/tests/unit/api/controllers/v1/test_driver.py index 61795ece3c..3499806767 100644 --- a/ironic/tests/unit/api/controllers/v1/test_driver.py +++ b/ironic/tests/unit/api/controllers/v1/test_driver.py @@ -208,7 +208,12 @@ class TestListDrivers(base.BaseApiTest): if use_dynamic: for iface in driver_base.ALL_INTERFACES: - if storage_if or iface != 'storage': + # TODO(stendulker): Remove this check in 'rescue' API + # patch. + if iface == 'rescue': + self.assertNotIn('default_rescue_interface', data) + self.assertNotIn('enabled_rescue_interfaces', data) + elif storage_if or iface != 'storage': self.assertIn('default_%s_interface' % iface, data) self.assertIn('enabled_%s_interfaces' % iface, data) self.assertIsNotNone(data['default_deploy_interface']) diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index be6faf14e3..eab547299e 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -2174,17 +2174,36 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('neutron', result['network_interface']) def test_create_node_specify_interfaces(self): - headers = {api_base.Version.string: '1.31'} - for field in api_utils.V31_FIELDS: - cfg.CONF.set_override('enabled_%ss' % field, ['fake']) - for field in api_utils.V31_FIELDS: + headers = {api_base.Version.string: '1.33'} + all_interface_fields = api_utils.V31_FIELDS + ['network_interface', + 'rescue_interface', + 'storage_interface'] + for field in all_interface_fields: + if field == 'network_interface': + cfg.CONF.set_override('enabled_%ss' % field, ['flat']) + elif field == 'storage_interface': + cfg.CONF.set_override('enabled_%ss' % field, ['noop']) + else: + cfg.CONF.set_override('enabled_%ss' % field, ['fake']) + + for field in all_interface_fields: + expected = 'fake' + if field == 'network_interface': + expected = 'flat' + elif field == 'storage_interface': + expected = 'noop' + elif field == 'rescue_interface': + # TODO(stendulker): Enable testing of rescue interface + # in its API patch. + continue + node = { 'uuid': uuidutils.generate_uuid(), - field: 'fake', + field: expected, 'driver': 'fake-hardware' } result = self._test_create_node(headers=headers, **node) - self.assertEqual('fake', result[field]) + self.assertEqual(expected, result[field]) def test_create_node_specify_interfaces_bad_version(self): headers = {api_base.Version.string: '1.30'} diff --git a/ironic/tests/unit/common/test_driver_factory.py b/ironic/tests/unit/common/test_driver_factory.py index daaf7193f9..dc0a011440 100644 --- a/ironic/tests/unit/common/test_driver_factory.py +++ b/ironic/tests/unit/common/test_driver_factory.py @@ -97,7 +97,10 @@ class DriverLoadTestCase(db_base.DbTestCase): with task_manager.acquire(self.context, node.id) as task: for iface in drivers_base.ALL_INTERFACES: impl = getattr(task.driver, iface) - self.assertIsNotNone(impl) + if iface == 'rescue': + self.assertIsNone(impl) + else: + self.assertIsNotNone(impl) @mock.patch.object(driver_factory, '_attach_interfaces_to_driver', autospec=True) @@ -580,6 +583,11 @@ class TestFakeHardware(hardware_type.AbstractHardwareType): """List of supported raid interfaces.""" return [fake.FakeRAID] + @property + def supported_rescue_interfaces(self): + """List of supported rescue interfaces.""" + return [fake.FakeRescue] + @property def supported_vendor_interfaces(self): """List of supported rescue interfaces.""" @@ -732,6 +740,25 @@ class HardwareTypeLoadTestCase(db_base.DbTestCase): driver_factory.check_and_update_node_interfaces, node) + def test_none_rescue_interface(self): + node = obj_utils.get_test_node(self.context, driver='fake') + self.assertTrue(driver_factory.check_and_update_node_interfaces(node)) + self.assertIsNone(node.rescue_interface) + + def test_no_rescue_interface_default_from_conf(self): + self.config(enabled_rescue_interfaces=['fake']) + self.config(default_rescue_interface='fake') + node = obj_utils.get_test_node(self.context, driver='fake-hardware') + self.assertTrue(driver_factory.check_and_update_node_interfaces(node)) + self.assertEqual('fake', node.rescue_interface) + + def test_invalid_rescue_interface(self): + node = obj_utils.get_test_node(self.context, driver='fake-hardware', + rescue_interface='scoop') + self.assertRaises(exception.InterfaceNotFoundInEntrypoint, + driver_factory.check_and_update_node_interfaces, + node) + def test_no_raid_interface_no_default(self): # NOTE(rloo): It doesn't seem possible to not have a default interface # for storage, so we'll test this case with raid. @@ -753,6 +780,7 @@ class HardwareTypeLoadTestCase(db_base.DbTestCase): 'network': set(['noop']), 'power': set(['fake']), 'raid': set(['fake']), + 'rescue': set(['fake']), 'storage': set([]), 'vendor': set(['fake']) } diff --git a/ironic/tests/unit/conductor/mgr_utils.py b/ironic/tests/unit/conductor/mgr_utils.py index 829c644de5..a166594696 100644 --- a/ironic/tests/unit/conductor/mgr_utils.py +++ b/ironic/tests/unit/conductor/mgr_utils.py @@ -175,6 +175,7 @@ class ServiceSetUpMixin(object): self.config(enabled_management_interfaces=['fake']) self.config(enabled_power_interfaces=['fake']) self.config(enabled_raid_interfaces=['fake', 'no-raid']) + self.config(enabled_rescue_interfaces=['fake', 'no-rescue']) self.config(enabled_vendor_interfaces=['fake', 'no-vendor']) self.service = manager.ConductorManager(self.hostname, 'test-topic') diff --git a/ironic/tests/unit/drivers/test_base.py b/ironic/tests/unit/drivers/test_base.py index 18858dc30a..52642ec159 100644 --- a/ironic/tests/unit/drivers/test_base.py +++ b/ironic/tests/unit/drivers/test_base.py @@ -447,6 +447,7 @@ class TestBareDriver(base.TestCase): self.assertEqual(('deploy', 'power', 'network'), driver_base.BareDriver.core_interfaces) self.assertEqual( - ('boot', 'console', 'inspect', 'management', 'raid', 'storage'), + ('boot', 'console', 'inspect', 'management', 'raid', + 'rescue', 'storage'), driver_base.BareDriver.standard_interfaces ) diff --git a/ironic/tests/unit/drivers/test_fake.py b/ironic/tests/unit/drivers/test_fake.py index c0a849f5a5..2709164295 100644 --- a/ironic/tests/unit/drivers/test_fake.py +++ b/ironic/tests/unit/drivers/test_fake.py @@ -49,7 +49,6 @@ class FakeDriverTestCase(db_base.DbTestCase): self.assertIsInstance(self.driver.vendor, driver_base.VendorInterface) self.assertIsInstance(self.driver.console, driver_base.ConsoleInterface) - self.assertIsNone(self.driver.rescue) def test_get_properties(self): expected = ['A1', 'A2', 'B1', 'B2'] diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 9dcfc2f188..a74bcdb3e7 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -260,3 +260,73 @@ class TestNodeObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): def test_payload_schemas(self): self._check_payload_schemas(objects.node, objects.Node.fields) + + +class TestConvertToVersion(db_base.DbTestCase): + + def setUp(self): + super(TestConvertToVersion, self).setUp() + self.ctxt = context.get_admin_context() + self.fake_node = db_utils.get_test_node(driver='fake-hardware') + + def test_rescue_supported_missing(self): + # rescue_interface not set, should be set to default. + node = objects.Node(self.context, **self.fake_node) + delattr(node, 'rescue_interface') + node.obj_reset_changes() + + node._convert_to_version("1.22") + + self.assertIsNone(node.rescue_interface) + self.assertEqual({'rescue_interface': None}, + node.obj_get_changes()) + + def test_rescue_supported_set(self): + # rescue_interface set, no change required. + node = objects.Node(self.context, **self.fake_node) + + node.rescue_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.22") + self.assertEqual('fake', node.rescue_interface) + self.assertEqual({}, node.obj_get_changes()) + + def test_rescue_unsupported_missing(self): + # rescue_interface not set, no change required. + node = objects.Node(self.context, **self.fake_node) + + delattr(node, 'rescue_interface') + node.obj_reset_changes() + node._convert_to_version("1.21") + self.assertNotIn('rescue_interface', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_rescue_unsupported_set_remove(self): + # rescue_interface set, should be removed. + node = objects.Node(self.context, **self.fake_node) + + node.rescue_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.21") + self.assertNotIn('rescue_interface', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_rescue_unsupported_set_no_remove_non_default(self): + # rescue_interface set, should be set to default. + node = objects.Node(self.context, **self.fake_node) + + node.rescue_interface = 'fake' + node.obj_reset_changes() + node._convert_to_version("1.21", False) + self.assertIsNone(node.rescue_interface) + self.assertEqual({'rescue_interface': None}, node.obj_get_changes()) + + def test_rescue_unsupported_set_no_remove_default(self): + # rescue_interface set, no change required. + node = objects.Node(self.context, **self.fake_node) + + node.rescue_interface = None + node.obj_reset_changes() + node._convert_to_version("1.21", False) + self.assertIsNone(node.rescue_interface) + self.assertEqual({}, node.obj_get_changes()) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 7a25b9cd39..c02659f4ea 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -684,7 +684,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.21-52674c214141cf3e09f8688bfed54577', + 'Node': '1.22-f2c453dd0b42aec8d4833a69a9ac79f3', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.7-898a47921f4a1f53fcdddd4eeb179e0b', diff --git a/setup.cfg b/setup.cfg index 9ed647b558..02f88f9cbd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -150,6 +150,7 @@ ironic.hardware.interfaces.raid = no-raid = ironic.drivers.modules.noop:NoRAID ironic.hardware.interfaces.rescue = + fake = ironic.drivers.modules.fake:FakeRescue no-rescue = ironic.drivers.modules.noop:NoRescue ironic.hardware.interfaces.storage =