From 8bdd538c0c387c8c2633c8615f2a15a31918a548 Mon Sep 17 00:00:00 2001
From: Dmitry Tantsur <dtantsur@redhat.com>
Date: Wed, 15 Jun 2016 17:30:33 +0200
Subject: [PATCH] Promote agent vendor passthru to core API

Introduces new /v1/lookup and /v1/heartbeat/<UUID> endpoints
(and associated controllers).

This change does not deprecate the old passthru endpoints, it should
be done after IPA switches to using the new ones.

Change-Id: I9080c07b03103cd7a323e2fc01be821733b07eea
Partial-Bug: #1570841
---
 devstack/lib/ironic                           |   2 +-
 doc/source/webapi/v1.rst                      |   4 +
 etc/ironic/ironic.conf.sample                 |  13 +-
 ironic/api/config.py                          |   3 +
 ironic/api/controllers/v1/__init__.py         |  26 +++
 ironic/api/controllers/v1/ramdisk.py          | 150 +++++++++++++++
 ironic/api/controllers/v1/types.py            |  21 +++
 ironic/api/controllers/v1/utils.py            |   8 +
 ironic/api/controllers/v1/versions.py         |   4 +-
 ironic/common/exception.py                    |   5 +
 ironic/conf/agent.py                          |   3 -
 ironic/conf/api.py                            |   8 +
 ironic/drivers/modules/agent_base_vendor.py   |  19 +-
 ironic/tests/unit/api/test_root.py            |  18 ++
 ironic/tests/unit/api/v1/test_ramdisk.py      | 172 ++++++++++++++++++
 ironic/tests/unit/api/v1/test_types.py        |  21 +++
 .../drivers/modules/test_agent_base_vendor.py |   2 +-
 .../lookup-heartbeat-f9772521d12a0549.yaml    |  17 ++
 18 files changed, 470 insertions(+), 26 deletions(-)
 create mode 100644 ironic/api/controllers/v1/ramdisk.py
 create mode 100644 ironic/tests/unit/api/v1/test_ramdisk.py
 create mode 100644 releasenotes/notes/lookup-heartbeat-f9772521d12a0549.yaml

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/<NODE>``.
+    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.