diff --git a/devstack/lib/ironic b/devstack/lib/ironic index 88953d0671..bbbcf79492 100644 --- a/devstack/lib/ironic +++ b/devstack/lib/ironic @@ -682,7 +682,7 @@ function configure_ironic_conductor { fi if is_deployed_by_agent; then - iniset $IRONIC_CONF_FILE agent heartbeat_timeout 30 + iniset $IRONIC_CONF_FILE api ramdisk_heartbeat_timeout 30 fi # FIXME: this really needs to be tested in the gate. For now, any diff --git a/doc/source/webapi/v1.rst b/doc/source/webapi/v1.rst index d60f8d46b2..64cadeca89 100644 --- a/doc/source/webapi/v1.rst +++ b/doc/source/webapi/v1.rst @@ -32,6 +32,10 @@ always requests the newest supported API version. API Versions History -------------------- +**1.22** + + Added endpoints for deployment ramdisks. + **1.21** Add node ``resource_class`` field. diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 0ada7a9fff..8e2e3eaf00 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -400,10 +400,6 @@ # be set to True. Defaults to True. (boolean value) #stream_raw_images = true -# Maximum interval (in seconds) for agent heartbeats. (integer -# value) -#heartbeat_timeout = 300 - # Number of times to retry getting power state to check if # bare metal node has been powered off after a soft power off. # (integer value) @@ -486,6 +482,15 @@ # 'public_endpoint' option. (boolean value) #enable_ssl_api = false +# Whether to restrict the lookup API to only nodes in certain +# states. (boolean value) +#restrict_lookup = true + +# Maximum interval (in seconds) for agent heartbeats. (integer +# value) +# Deprecated group/name - [agent]/heartbeat_timeout +#ramdisk_heartbeat_timeout = 300 + [audit] diff --git a/ironic/api/config.py b/ironic/api/config.py index f707f5b4a2..abf7d24c81 100644 --- a/ironic/api/config.py +++ b/ironic/api/config.py @@ -30,6 +30,9 @@ app = { '/', '/v1', # IPA ramdisk methods + '/v1/lookup', + '/v1/heartbeat/[a-z0-9\-]+', + # Old IPA ramdisk methods - will be removed in the Ocata release '/v1/drivers/[a-z0-9_]*/vendor_passthru/lookup', '/v1/nodes/[a-z0-9\-]+/vendor_passthru/heartbeat', ], diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index cda8e41b87..5d285fdd28 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -29,6 +29,8 @@ from ironic.api.controllers.v1 import chassis from ironic.api.controllers.v1 import driver from ironic.api.controllers.v1 import node from ironic.api.controllers.v1 import port +from ironic.api.controllers.v1 import ramdisk +from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import versions from ironic.api import expose from ironic.common.i18n import _ @@ -78,6 +80,12 @@ class V1(base.APIBase): drivers = [link.Link] """Links to the drivers resource""" + lookup = [link.Link] + """Links to the lookup resource""" + + heartbeat = [link.Link] + """Links to the heartbeat resource""" + @staticmethod def convert(): v1 = V1() @@ -120,6 +128,22 @@ class V1(base.APIBase): 'drivers', '', bookmark=True) ] + if utils.allow_ramdisk_endpoints(): + v1.lookup = [link.Link.make_link('self', pecan.request.public_url, + 'lookup', ''), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'lookup', '', + bookmark=True) + ] + v1.heartbeat = [link.Link.make_link('self', + pecan.request.public_url, + 'heartbeat', ''), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'heartbeat', '', + bookmark=True) + ] return v1 @@ -130,6 +154,8 @@ class Controller(rest.RestController): ports = port.PortsController() chassis = chassis.ChassisController() drivers = driver.DriversController() + lookup = ramdisk.LookupController() + heartbeat = ramdisk.HeartbeatController() @expose.expose(V1) def get(self): diff --git a/ironic/api/controllers/v1/ramdisk.py b/ironic/api/controllers/v1/ramdisk.py new file mode 100644 index 0000000000..ed9c77b29b --- /dev/null +++ b/ironic/api/controllers/v1/ramdisk.py @@ -0,0 +1,150 @@ +# Copyright 2016 Red Hat, Inc. +# +# 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 +import pecan +from pecan import rest +from six.moves import http_client +from wsme import types as wtypes + +from ironic.api.controllers import base +from ironic.api.controllers.v1 import node as node_ctl +from ironic.api.controllers.v1 import types +from ironic.api.controllers.v1 import utils as api_utils +from ironic.api import expose +from ironic.common import exception +from ironic.common import policy +from ironic.common import states +from ironic import objects + + +CONF = cfg.CONF + +_LOOKUP_RETURN_FIELDS = ('uuid', 'properties', 'instance_info', + 'driver_internal_info') +_LOOKUP_ALLOWED_STATES = {states.DEPLOYING, states.DEPLOYWAIT, + states.CLEANING, states.CLEANWAIT, + states.INSPECTING} + + +def config(): + return { + 'metrics': { + 'backend': CONF.metrics.agent_backend, + 'prepend_host': CONF.metrics.agent_prepend_host, + 'prepend_uuid': CONF.metrics.agent_prepend_uuid, + 'prepend_host_reverse': CONF.metrics.agent_prepend_host_reverse, + 'global_prefix': CONF.metrics.agent_global_prefix + }, + 'metrics_statsd': { + 'statsd_host': CONF.metrics_statsd.agent_statsd_host, + 'statsd_port': CONF.metrics_statsd.agent_statsd_port + }, + 'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout + } + + +class LookupResult(base.APIBase): + """API representation of the node lookup result.""" + + node = node_ctl.Node + """The short node representation.""" + + config = {wtypes.text: types.jsontype} + """The configuration to pass to the ramdisk.""" + + @classmethod + def sample(cls): + return cls(node=node_ctl.Node.sample(), + config={'heartbeat_timeout': 600}) + + @classmethod + def convert_with_links(cls, node): + node = node_ctl.Node.convert_with_links(node, _LOOKUP_RETURN_FIELDS) + return cls(node=node, config=config()) + + +class LookupController(rest.RestController): + """Controller handling node lookup for a deploy ramdisk.""" + + @expose.expose(LookupResult, types.list_of_macaddress, types.uuid) + def get_all(self, addresses=None, node_uuid=None): + """Look up a node by its MAC addresses and optionally UUID. + + If the "restrict_lookup" option is set to True (the default), limit + the search to nodes in certain transient states (e.g. deploy wait). + + :param addresses: list of MAC addresses for a node. + :param node_uuid: UUID of a node. + :raises: NotFound if requested API version does not allow this + endpoint. + :raises: NotFound if suitable node was not found. + """ + if not api_utils.allow_ramdisk_endpoints(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:driver:ipa_lookup', cdict, cdict) + + if not addresses and not node_uuid: + raise exception.IncompleteLookup() + + try: + if node_uuid: + node = objects.Node.get_by_uuid( + pecan.request.context, node_uuid) + else: + node = objects.Node.get_by_port_addresses( + pecan.request.context, addresses) + except exception.NotFound: + # NOTE(dtantsur): we are reraising the same exception to make sure + # we don't disclose the difference between nodes that are not found + # at all and nodes in a wrong state by different error messages. + raise exception.NotFound() + + if (CONF.api.restrict_lookup and + node.provision_state not in _LOOKUP_ALLOWED_STATES): + raise exception.NotFound() + + return LookupResult.convert_with_links(node) + + +class HeartbeatController(rest.RestController): + """Controller handling heartbeats from deploy ramdisk.""" + + @expose.expose(None, types.uuid_or_name, wtypes.text, + status_code=http_client.ACCEPTED) + def post(self, node_ident, callback_url): + """Process a heartbeat from the deploy ramdisk. + + :param node_ident: the UUID or logical name of a node. + :param callback_url: the URL to reach back to the ramdisk. + """ + if not api_utils.allow_ramdisk_endpoints(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:node:ipa_heartbeat', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + + try: + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + except exception.NoValidHost as e: + e.code = http_client.BAD_REQUEST + raise + + pecan.request.rpcapi.heartbeat(pecan.request.context, + rpc_node.uuid, callback_url, + topic=topic) diff --git a/ironic/api/controllers/v1/types.py b/ironic/api/controllers/v1/types.py index 9cfe206b79..5979ace4af 100644 --- a/ironic/api/controllers/v1/types.py +++ b/ironic/api/controllers/v1/types.py @@ -176,6 +176,26 @@ class ListType(wtypes.UserType): return ListType.validate(value) +class ListOfMacAddressesType(ListType): + """List of MAC addresses.""" + + @staticmethod + def validate(value): + """Validate and convert the input to a ListOfMacAddressesType. + + :param value: A comma separated string of MAC addresses. + :returns: A list of unique MACs, whose order is not guaranteed. + """ + items = ListType.validate(value) + return [MacAddressType.validate(item) for item in items] + + @staticmethod + def frombasetype(value): + if value is None: + return None + return ListOfMacAddressesType.validate(value) + + macaddress = MacAddressType() uuid_or_name = UuidOrNameType() name = NameType() @@ -184,6 +204,7 @@ boolean = BooleanType() listtype = ListType() # Can't call it 'json' because that's the name of the stdlib module jsontype = JsonType() +list_of_macaddress = ListOfMacAddressesType() class JsonPatchType(wtypes.Base): diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 00b6a17d99..6c0cba7671 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -383,6 +383,14 @@ def allow_resource_class(): versions.MINOR_21_RESOURCE_CLASS) +def allow_ramdisk_endpoints(): + """Check if heartbeat and lookup endpoints are allowed. + + Version 1.22 of the API introduced them. + """ + return pecan.request.version.minor >= versions.MINOR_22_LOOKUP_HEARTBEAT + + def get_controller_reserved_names(cls): """Get reserved names for a given controller. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 152f5e64a9..aa02fb930b 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -51,6 +51,7 @@ BASE_VERSION = 1 # v1.19: Add port.local_link_connection and port.pxe_enabled. # v1.20: Add node.network_interface # v1.21: Add node.resource_class +# v1.22: Ramdisk lookup and heartbeat endpoints. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -74,11 +75,12 @@ MINOR_18_PORT_INTERNAL_INFO = 18 MINOR_19_PORT_ADVANCED_NET_FIELDS = 19 MINOR_20_NETWORK_INTERFACE = 20 MINOR_21_RESOURCE_CLASS = 21 +MINOR_22_LOOKUP_HEARTBEAT = 22 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/webapi/v1.rst with a detailed explanation of what the version has # changed. -MINOR_MAX_VERSION = MINOR_21_RESOURCE_CLASS +MINOR_MAX_VERSION = MINOR_22_LOOKUP_HEARTBEAT # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 7d8341c15e..b483b8d7de 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -606,3 +606,8 @@ class NodeTagNotFound(IronicException): class NetworkError(IronicException): _msg_fmt = _("Network operation failure.") + + +class IncompleteLookup(Invalid): + _msg_fmt = _("At least one of 'addresses' and 'node_uuid' parameters " + "is required") diff --git a/ironic/conf/agent.py b/ironic/conf/agent.py index 9555ca973c..899e8afb8a 100644 --- a/ironic/conf/agent.py +++ b/ironic/conf/agent.py @@ -44,9 +44,6 @@ opts = [ 'to the disk. Unless the disk where the image will be ' 'copied to is really slow, this option should be set ' 'to True. Defaults to True.')), - cfg.IntOpt('heartbeat_timeout', - default=300, - help=_('Maximum interval (in seconds) for agent heartbeats.')), cfg.IntOpt('post_deploy_get_power_state_retries', default=6, help=_('Number of times to retry getting power state to check ' diff --git a/ironic/conf/api.py b/ironic/conf/api.py index 7ec6f36c27..d9b74414f5 100644 --- a/ironic/conf/api.py +++ b/ironic/conf/api.py @@ -49,6 +49,14 @@ opts = [ "the service, this option should be False; note, you " "will want to change public API endpoint to represent " "SSL termination URL with 'public_endpoint' option.")), + cfg.BoolOpt('restrict_lookup', + default=True, + help=_('Whether to restrict the lookup API to only nodes ' + 'in certain states.')), + cfg.IntOpt('ramdisk_heartbeat_timeout', + default=300, + deprecated_group='agent', deprecated_name='heartbeat_timeout', + help=_('Maximum interval (in seconds) for agent heartbeats.')), ] opt_group = cfg.OptGroup(name='api', diff --git a/ironic/drivers/modules/agent_base_vendor.py b/ironic/drivers/modules/agent_base_vendor.py index 51a6fc40e0..d7929596cf 100644 --- a/ironic/drivers/modules/agent_base_vendor.py +++ b/ironic/drivers/modules/agent_base_vendor.py @@ -26,6 +26,7 @@ from oslo_utils import strutils from oslo_utils import timeutils import retrying +from ironic.api.controllers.v1 import ramdisk from ironic.common import boot_devices from ironic.common import exception from ironic.common.i18n import _ @@ -789,23 +790,9 @@ class BaseAgentVendor(AgentDeployMixin, base.VendorInterface): # config namespace. Instead of a separate deprecation, # this will die when the vendor_passthru version of # lookup goes away. - 'heartbeat_timeout': CONF.agent.heartbeat_timeout, + 'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout, 'node': ndict, - 'config': { - 'metrics': { - 'backend': CONF.metrics.agent_backend, - 'prepend_host': CONF.metrics.agent_prepend_host, - 'prepend_uuid': CONF.metrics.agent_prepend_uuid, - 'prepend_host_reverse': - CONF.metrics.agent_prepend_host_reverse, - 'global_prefix': CONF.metrics.agent_global_prefix - }, - 'metrics_statsd': { - 'statsd_host': CONF.metrics_statsd.agent_statsd_host, - 'statsd_port': CONF.metrics_statsd.agent_statsd_port - }, - 'heartbeat_timeout': CONF.agent.heartbeat_timeout - } + 'config': ramdisk.config(), } def _get_interfaces(self, inventory): diff --git a/ironic/tests/unit/api/test_root.py b/ironic/tests/unit/api/test_root.py index 3f41242f9b..ed5e9676bb 100644 --- a/ironic/tests/unit/api/test_root.py +++ b/ironic/tests/unit/api/test_root.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +from ironic.api.controllers import base as api_base from ironic.api.controllers.v1 import versions from ironic.tests.unit.api import base @@ -51,3 +52,20 @@ class TestV1Root(base.BaseApiTest): self.assertIn({'type': 'application/vnd.openstack.ironic.v1+json', 'base': 'application/json'}, data['media_types']) + + def test_get_v1_root_version_1_22(self): + headers = {api_base.Version.string: '1.22'} + data = self.get_json('/', headers=headers) + self.assertEqual('v1', data['id']) + # Check fields are not empty + for f in data: + self.assertNotIn(f, ['', []]) + # Check if all known resources are present and there are no extra ones. + not_resources = ('id', 'links', 'media_types') + actual_resources = tuple(set(data.keys()) - set(not_resources)) + expected_resources = ('chassis', 'drivers', 'heartbeat', + 'lookup', 'nodes', 'ports') + self.assertEqual(sorted(expected_resources), sorted(actual_resources)) + + self.assertIn({'type': 'application/vnd.openstack.ironic.v1+json', + 'base': 'application/json'}, data['media_types']) diff --git a/ironic/tests/unit/api/v1/test_ramdisk.py b/ironic/tests/unit/api/v1/test_ramdisk.py new file mode 100644 index 0000000000..601747ec94 --- /dev/null +++ b/ironic/tests/unit/api/v1/test_ramdisk.py @@ -0,0 +1,172 @@ +# Copyright 2016 Red Hat, Inc. +# +# 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. +""" +Tests for the API /lookup/ methods. +""" + +import mock +from oslo_config import cfg +from oslo_utils import uuidutils +from six.moves import http_client + +from ironic.api.controllers import base as api_base +from ironic.api.controllers import v1 as api_v1 +from ironic.api.controllers.v1 import ramdisk +from ironic.conductor import rpcapi +from ironic.tests.unit.api import base as test_api_base +from ironic.tests.unit.objects import utils as obj_utils + + +CONF = cfg.CONF + + +class TestLookup(test_api_base.BaseApiTest): + addresses = ['11:22:33:44:55:66', '66:55:44:33:22:11'] + + def setUp(self): + super(TestLookup, self).setUp() + self.node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state='deploying') + self.node2 = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state='available') + CONF.set_override('agent_backend', 'statsd', 'metrics') + + def _check_config(self, data): + expected_metrics = { + 'metrics': { + 'backend': 'statsd', + 'prepend_host': CONF.metrics.agent_prepend_host, + 'prepend_uuid': CONF.metrics.agent_prepend_uuid, + 'prepend_host_reverse': + CONF.metrics.agent_prepend_host_reverse, + 'global_prefix': CONF.metrics.agent_global_prefix + }, + 'metrics_statsd': { + 'statsd_host': CONF.metrics_statsd.agent_statsd_host, + 'statsd_port': CONF.metrics_statsd.agent_statsd_port + }, + 'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout + } + self.assertEqual(expected_metrics, data['config']) + + def test_nothing_provided(self): + response = self.get_json( + '/lookup', + headers={api_base.Version.string: str(api_v1.MAX_VER)}, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + def test_not_found(self): + response = self.get_json( + '/lookup?addresses=%s' % ','.join(self.addresses), + headers={api_base.Version.string: str(api_v1.MAX_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_old_api_version(self): + obj_utils.create_test_port(self.context, + node_id=self.node.id, + address=self.addresses[1]) + + response = self.get_json( + '/lookup?addresses=%s' % ','.join(self.addresses), + headers={api_base.Version.string: str(api_v1.MIN_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_found_by_addresses(self): + obj_utils.create_test_port(self.context, + node_id=self.node.id, + address=self.addresses[1]) + + data = self.get_json( + '/lookup?addresses=%s' % ','.join(self.addresses), + headers={api_base.Version.string: str(api_v1.MAX_VER)}) + self.assertEqual(self.node.uuid, data['node']['uuid']) + self.assertEqual(set(ramdisk._LOOKUP_RETURN_FIELDS) | {'links'}, + set(data['node'])) + self._check_config(data) + + def test_found_by_uuid(self): + data = self.get_json( + '/lookup?addresses=%s&node_uuid=%s' % + (','.join(self.addresses), self.node.uuid), + headers={api_base.Version.string: str(api_v1.MAX_VER)}) + self.assertEqual(self.node.uuid, data['node']['uuid']) + self.assertEqual(set(ramdisk._LOOKUP_RETURN_FIELDS) | {'links'}, + set(data['node'])) + self._check_config(data) + + def test_found_by_only_uuid(self): + data = self.get_json( + '/lookup?node_uuid=%s' % self.node.uuid, + headers={api_base.Version.string: str(api_v1.MAX_VER)}) + self.assertEqual(self.node.uuid, data['node']['uuid']) + self.assertEqual(set(ramdisk._LOOKUP_RETURN_FIELDS) | {'links'}, + set(data['node'])) + self._check_config(data) + + def test_restrict_lookup(self): + response = self.get_json( + '/lookup?addresses=%s&node_uuid=%s' % + (','.join(self.addresses), self.node2.uuid), + headers={api_base.Version.string: str(api_v1.MAX_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_no_restrict_lookup(self): + CONF.set_override('restrict_lookup', False, 'api') + data = self.get_json( + '/lookup?addresses=%s&node_uuid=%s' % + (','.join(self.addresses), self.node2.uuid), + headers={api_base.Version.string: str(api_v1.MAX_VER)}) + self.assertEqual(self.node2.uuid, data['node']['uuid']) + self.assertEqual(set(ramdisk._LOOKUP_RETURN_FIELDS) | {'links'}, + set(data['node'])) + self._check_config(data) + + +@mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for', + lambda *n: 'test-topic') +class TestHeartbeat(test_api_base.BaseApiTest): + def test_old_api_version(self): + response = self.post_json( + '/heartbeat/%s' % uuidutils.generate_uuid(), + {'callback_url': 'url'}, + headers={api_base.Version.string: str(api_v1.MIN_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_node_not_found(self): + response = self.post_json( + '/heartbeat/%s' % uuidutils.generate_uuid(), + {'callback_url': 'url'}, + headers={api_base.Version.string: str(api_v1.MAX_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + @mock.patch.object(rpcapi.ConductorAPI, 'heartbeat', autospec=True) + def test_ok(self, mock_heartbeat): + node = obj_utils.create_test_node(self.context) + response = self.post_json( + '/heartbeat/%s' % node.uuid, + {'callback_url': 'url'}, + headers={api_base.Version.string: str(api_v1.MAX_VER)}) + self.assertEqual(http_client.ACCEPTED, response.status_int) + self.assertEqual(b'', response.body) + mock_heartbeat.assert_called_once_with(mock.ANY, mock.ANY, + node.uuid, 'url', + topic='test-topic') diff --git a/ironic/tests/unit/api/v1/test_types.py b/ironic/tests/unit/api/v1/test_types.py index 4c3035d912..0c7e5ce450 100644 --- a/ironic/tests/unit/api/v1/test_types.py +++ b/ironic/tests/unit/api/v1/test_types.py @@ -41,6 +41,27 @@ class TestMacAddressType(base.TestCase): types.MacAddressType.validate, 'invalid-mac') +class TestListOfMacAddressesType(base.TestCase): + + def test_valid_mac_addr(self): + test_mac = 'aa:bb:cc:11:22:33' + self.assertEqual([test_mac], + types.ListOfMacAddressesType.validate(test_mac)) + + def test_valid_list(self): + test_mac = 'aa:bb:cc:11:22:33,11:22:33:44:55:66' + self.assertEqual( + sorted(test_mac.split(',')), + sorted(types.ListOfMacAddressesType.validate(test_mac))) + + def test_invalid_mac_addr(self): + self.assertRaises(exception.InvalidMAC, + types.ListOfMacAddressesType.validate, 'invalid-mac') + self.assertRaises(exception.InvalidMAC, + types.ListOfMacAddressesType.validate, + 'aa:bb:cc:11:22:33,invalid-mac') + + class TestUuidType(base.TestCase): def test_valid_uuid(self): diff --git a/ironic/tests/unit/drivers/modules/test_agent_base_vendor.py b/ironic/tests/unit/drivers/modules/test_agent_base_vendor.py index 78791bc58b..7cbc02956b 100644 --- a/ironic/tests/unit/drivers/modules/test_agent_base_vendor.py +++ b/ironic/tests/unit/drivers/modules/test_agent_base_vendor.py @@ -132,7 +132,7 @@ class TestBaseAgentVendor(db_base.DbTestCase): 'statsd_host': CONF.metrics_statsd.agent_statsd_host, 'statsd_port': CONF.metrics_statsd.agent_statsd_port }, - 'heartbeat_timeout': CONF.agent.heartbeat_timeout + 'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout } find_mock.return_value = self.node diff --git a/releasenotes/notes/lookup-heartbeat-f9772521d12a0549.yaml b/releasenotes/notes/lookup-heartbeat-f9772521d12a0549.yaml new file mode 100644 index 0000000000..f4ec0cd6fd --- /dev/null +++ b/releasenotes/notes/lookup-heartbeat-f9772521d12a0549.yaml @@ -0,0 +1,17 @@ +--- +features: + - New API endpoint for deploy ramdisk lookup ``/v1/lookup``. + This endpoint is not authenticated to allow ramdisks to access it without + passing the credentials to them. + - New API endpoint for deploy ramdisk heartbeat ``/v1/heartbeat/``. + This endpoint is not authenticated to allow ramdisks to access it without + passing the credentials to them. +deprecations: + - The configuration option ``[agent]heartbeat_timeout`` was renamed to + ``[api]ramdisk_heartbeat_timeout``. The old variant is deprecated. +upgrade: + - A new configuration option ``[api]restrict_lookup`` is added, which + restricts the lookup API (normally only used by ramdisks) to only work when + the node is in specific states used by the ramdisk, and defaults to True. + Operators that need this endpoint to work in any state may set this to + False, though this is insecure and should not be used in normal operation.