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
This commit is contained in:
Dmitry Tantsur 2016-06-15 17:30:33 +02:00 committed by Dmitry Tantsur
parent bc106b56bb
commit 8bdd538c0c
18 changed files with 470 additions and 26 deletions

View File

@ -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

View File

@ -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.

View File

@ -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]

View File

@ -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',
],

View File

@ -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):

View File

@ -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)

View File

@ -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):

View File

@ -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.

View File

@ -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)

View File

@ -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")

View File

@ -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 '

View File

@ -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',

View File

@ -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):

View File

@ -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'])

View File

@ -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')

View File

@ -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):

View File

@ -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

View File

@ -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.