Add node.resource_class field

This adds the "resource_class" field to the node table, object, and API,
as well as a database migration to go with it.

Change-Id: I936f2e7b2f4d26e01354e826e5595ff021c3a55c
Partial-Bug: #1604916
This commit is contained in:
Jim Rollenhagen 2016-07-20 14:20:45 -07:00
parent 246e886dde
commit f16c6570bf
16 changed files with 348 additions and 30 deletions

View File

@ -32,6 +32,10 @@ always requests the newest supported API version.
API Versions History API Versions History
-------------------- --------------------
**1.21**
Add node ``resource_class`` field.
**1.20** **1.20**
Add node ``network_interface`` field. Add node ``network_interface`` field.

View File

@ -140,6 +140,9 @@ def hide_fields_in_newer_versions(obj):
if pecan.request.version.minor < versions.MINOR_20_NETWORK_INTERFACE: if pecan.request.version.minor < versions.MINOR_20_NETWORK_INTERFACE:
obj.network_interface = wsme.Unset obj.network_interface = wsme.Unset
if not api_utils.allow_resource_class():
obj.resource_class = wsme.Unset
def update_state_in_older_versions(obj): def update_state_in_older_versions(obj):
"""Change provision state names for API backwards compatability. """Change provision state names for API backwards compatability.
@ -699,6 +702,11 @@ class Node(base.APIBase):
extra = {wtypes.text: types.jsontype} extra = {wtypes.text: types.jsontype}
"""This node's meta data""" """This node's meta data"""
resource_class = wsme.wsattr(wtypes.StringType(max_length=80))
"""The resource class for the node, useful for classifying or grouping
nodes. Used, for example, to classify nodes in Nova's placement
engine."""
# NOTE: properties should use a class to enforce required properties # NOTE: properties should use a class to enforce required properties
# current list: arch, cpus, disk, ram, image # current list: arch, cpus, disk, ram, image
properties = {wtypes.text: types.jsontype} properties = {wtypes.text: types.jsontype}
@ -819,7 +827,7 @@ class Node(base.APIBase):
inspection_finished_at=None, inspection_started_at=time, inspection_finished_at=None, inspection_started_at=time,
console_enabled=False, clean_step={}, console_enabled=False, clean_step={},
raid_config=None, target_raid_config=None, raid_config=None, target_raid_config=None,
network_interface='flat') network_interface='flat', resource_class='baremetal-gold')
# NOTE(matty_dubs): The chassis_uuid getter() is based on the # NOTE(matty_dubs): The chassis_uuid getter() is based on the
# _chassis_uuid variable: # _chassis_uuid variable:
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
@ -1006,6 +1014,7 @@ class NodesController(rest.RestController):
def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated, def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated,
maintenance, provision_state, marker, limit, maintenance, provision_state, marker, limit,
sort_key, sort_dir, driver=None, sort_key, sort_dir, driver=None,
resource_class=None,
resource_url=None, fields=None): resource_url=None, fields=None):
if self.from_chassis and not chassis_uuid: if self.from_chassis and not chassis_uuid:
raise exception.MissingParameterValue( raise exception.MissingParameterValue(
@ -1038,6 +1047,8 @@ class NodesController(rest.RestController):
filters['provision_state'] = provision_state filters['provision_state'] = provision_state
if driver: if driver:
filters['driver'] = driver filters['driver'] = driver
if resource_class is not None:
filters['resource_class'] = resource_class
nodes = objects.Node.list(pecan.request.context, limit, marker_obj, nodes = objects.Node.list(pecan.request.context, limit, marker_obj,
sort_key=sort_key, sort_dir=sort_dir, sort_key=sort_key, sort_dir=sort_dir,
@ -1128,11 +1139,11 @@ class NodesController(rest.RestController):
@METRICS.timer('NodesController.get_all') @METRICS.timer('NodesController.get_all')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text, types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text, types.listtype) wtypes.text, wtypes.text, types.listtype, wtypes.text)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None, limit=None, sort_key='id', sort_dir='asc', driver=None,
fields=None): fields=None, resource_class=None):
"""Retrieve a list of nodes. """Retrieve a list of nodes.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for :param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@ -1153,28 +1164,34 @@ class NodesController(rest.RestController):
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param driver: Optional string value to get only nodes using that :param driver: Optional string value to get only nodes using that
driver. driver.
:param resource_class: Optional string value to get only nodes with
that resource_class.
:param fields: Optional, a list with a specified set of fields :param fields: Optional, a list with a specified set of fields
of the resource to be returned. of the resource to be returned.
""" """
api_utils.check_allow_specify_fields(fields) api_utils.check_allow_specify_fields(fields)
api_utils.check_allowed_fields(fields)
api_utils.check_for_invalid_state_and_allow_filter(provision_state) api_utils.check_for_invalid_state_and_allow_filter(provision_state)
api_utils.check_allow_specify_driver(driver) api_utils.check_allow_specify_driver(driver)
api_utils.check_allow_specify_network_interface_in_fields(fields) api_utils.check_allow_specify_resource_class(resource_class)
if fields is None: if fields is None:
fields = _DEFAULT_RETURN_FIELDS fields = _DEFAULT_RETURN_FIELDS
return self._get_nodes_collection(chassis_uuid, instance_uuid, return self._get_nodes_collection(chassis_uuid, instance_uuid,
associated, maintenance, associated, maintenance,
provision_state, marker, provision_state, marker,
limit, sort_key, sort_dir, limit, sort_key, sort_dir,
driver, fields=fields) driver=driver,
resource_class=resource_class,
fields=fields)
@METRICS.timer('NodesController.detail') @METRICS.timer('NodesController.detail')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean,
types.boolean, wtypes.text, types.uuid, int, wtypes.text, types.boolean, wtypes.text, types.uuid, int, wtypes.text,
wtypes.text, wtypes.text) wtypes.text, wtypes.text, wtypes.text)
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, provision_state=None, marker=None,
limit=None, sort_key='id', sort_dir='asc', driver=None): limit=None, sort_key='id', sort_dir='asc', driver=None,
resource_class=None):
"""Retrieve a list of nodes with detail. """Retrieve a list of nodes with detail.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for :param chassis_uuid: Optional UUID of a chassis, to get only nodes for
@ -1195,9 +1212,12 @@ class NodesController(rest.RestController):
:param sort_dir: direction to sort. "asc" or "desc". Default: asc. :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param driver: Optional string value to get only nodes using that :param driver: Optional string value to get only nodes using that
driver. driver.
:param resource_class: Optional string value to get only nodes with
that resource_class.
""" """
api_utils.check_for_invalid_state_and_allow_filter(provision_state) api_utils.check_for_invalid_state_and_allow_filter(provision_state)
api_utils.check_allow_specify_driver(driver) api_utils.check_allow_specify_driver(driver)
api_utils.check_allow_specify_resource_class(resource_class)
# /detail should only work against collections # /detail should only work against collections
parent = pecan.request.path.split('/')[:-1][-1] parent = pecan.request.path.split('/')[:-1][-1]
if parent != "nodes": if parent != "nodes":
@ -1208,7 +1228,9 @@ class NodesController(rest.RestController):
associated, maintenance, associated, maintenance,
provision_state, marker, provision_state, marker,
limit, sort_key, sort_dir, limit, sort_key, sort_dir,
driver, resource_url) driver=driver,
resource_class=resource_class,
resource_url=resource_url)
@METRICS.timer('NodesController.validate') @METRICS.timer('NodesController.validate')
@expose.expose(wtypes.text, types.uuid_or_name, types.uuid) @expose.expose(wtypes.text, types.uuid_or_name, types.uuid)
@ -1247,7 +1269,7 @@ class NodesController(rest.RestController):
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
api_utils.check_allow_specify_fields(fields) api_utils.check_allow_specify_fields(fields)
api_utils.check_allow_specify_network_interface_in_fields(fields) api_utils.check_allowed_fields(fields)
rpc_node = api_utils.get_rpc_node(node_ident) rpc_node = api_utils.get_rpc_node(node_ident)
return Node.convert_with_links(rpc_node, fields=fields) return Node.convert_with_links(rpc_node, fields=fields)
@ -1262,6 +1284,10 @@ class NodesController(rest.RestController):
if self.from_chassis: if self.from_chassis:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
if (not api_utils.allow_resource_class() and
node.resource_class is not wtypes.Unset):
raise exception.NotAcceptable()
n_interface = node.network_interface n_interface = node.network_interface
if (not api_utils.allow_network_interface() and if (not api_utils.allow_network_interface() and
n_interface is not wtypes.Unset): n_interface is not wtypes.Unset):
@ -1322,6 +1348,10 @@ class NodesController(rest.RestController):
if self.from_chassis: if self.from_chassis:
raise exception.OperationNotPermitted() raise exception.OperationNotPermitted()
resource_class = api_utils.get_patch_values(patch, '/resource_class')
if resource_class and not api_utils.allow_resource_class():
raise exception.NotAcceptable()
n_interfaces = api_utils.get_patch_values(patch, '/network_interface') n_interfaces = api_utils.get_patch_values(patch, '/network_interface')
if n_interfaces and not api_utils.allow_network_interface(): if n_interfaces and not api_utils.allow_network_interface():
raise exception.NotAcceptable() raise exception.NotAcceptable()

View File

@ -240,16 +240,17 @@ def check_allow_specify_fields(fields):
raise exception.NotAcceptable() raise exception.NotAcceptable()
def check_allow_specify_network_interface_in_fields(fields): def check_allowed_fields(fields):
"""Check if fetching a network_interface attribute is allowed. """Check if fetching a particular field is allowed.
Version 1.20 of the API allows to fetching a network_interface This method checks if the required version is being requested for fields
attribute. This method check if the required version is being that are only allowed to be fetched in a particular API version.
requested.
""" """
if (fields is not None if fields is None:
and 'network_interface' in fields return
and not allow_network_interface()): if 'network_interface' in fields and not allow_network_interface():
raise exception.NotAcceptable()
if 'resource_class' in fields and not allow_resource_class():
raise exception.NotAcceptable() raise exception.NotAcceptable()
@ -303,6 +304,20 @@ def check_allow_specify_driver(driver):
'opr': versions.MINOR_16_DRIVER_FILTER}) 'opr': versions.MINOR_16_DRIVER_FILTER})
def check_allow_specify_resource_class(resource_class):
"""Check if filtering nodes by resource_class is allowed.
Version 1.21 of the API allows filtering nodes by resource_class.
"""
if (resource_class is not None and pecan.request.version.minor <
versions.MINOR_21_RESOURCE_CLASS):
raise exception.NotAcceptable(_(
"Request not acceptable. The minimal required API version "
"should be %(base)s.%(opr)s") %
{'base': versions.BASE_VERSION,
'opr': versions.MINOR_21_RESOURCE_CLASS})
def initial_node_provision_state(): def initial_node_provision_state():
"""Return node state to use by default when creating new nodes. """Return node state to use by default when creating new nodes.
@ -359,6 +374,15 @@ def allow_network_interface():
versions.MINOR_20_NETWORK_INTERFACE) versions.MINOR_20_NETWORK_INTERFACE)
def allow_resource_class():
"""Check if we should support resource_class node field.
Version 1.21 of the API added support for resource_class.
"""
return (pecan.request.version.minor >=
versions.MINOR_21_RESOURCE_CLASS)
def get_controller_reserved_names(cls): def get_controller_reserved_names(cls):
"""Get reserved names for a given controller. """Get reserved names for a given controller.

View File

@ -50,6 +50,7 @@ BASE_VERSION = 1
# v1.18: Add port.internal_info. # v1.18: Add port.internal_info.
# v1.19: Add port.local_link_connection and port.pxe_enabled. # v1.19: Add port.local_link_connection and port.pxe_enabled.
# v1.20: Add node.network_interface # v1.20: Add node.network_interface
# v1.21: Add node.resource_class
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -72,11 +73,12 @@ MINOR_17_ADOPT_VERB = 17
MINOR_18_PORT_INTERNAL_INFO = 18 MINOR_18_PORT_INTERNAL_INFO = 18
MINOR_19_PORT_ADVANCED_NET_FIELDS = 19 MINOR_19_PORT_ADVANCED_NET_FIELDS = 19
MINOR_20_NETWORK_INTERFACE = 20 MINOR_20_NETWORK_INTERFACE = 20
MINOR_21_RESOURCE_CLASS = 21
# When adding another version, update MINOR_MAX_VERSION and also update # 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 # doc/source/webapi/v1.rst with a detailed explanation of what the version has
# changed. # changed.
MINOR_MAX_VERSION = MINOR_20_NETWORK_INTERFACE MINOR_MAX_VERSION = MINOR_21_RESOURCE_CLASS
# String representations of the minor and maximum versions # String representations of the minor and maximum versions
MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -0,0 +1,33 @@
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""add resource_class to node
Revision ID: dd34e1f1303b
Revises: 10b163d4481e
Create Date: 2016-07-20 21:48:12.475320
"""
# revision identifiers, used by Alembic.
revision = 'dd34e1f1303b'
down_revision = '10b163d4481e'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('resource_class', sa.String(80),
nullable=True))

View File

@ -214,6 +214,8 @@ class Connection(api.Connection):
query = query.filter_by(maintenance=filters['maintenance']) query = query.filter_by(maintenance=filters['maintenance'])
if 'driver' in filters: if 'driver' in filters:
query = query.filter_by(driver=filters['driver']) query = query.filter_by(driver=filters['driver'])
if 'resource_class' in filters:
query = query.filter_by(resource_class=filters['resource_class'])
if 'provision_state' in filters: if 'provision_state' in filters:
query = query.filter_by(provision_state=filters['provision_state']) query = query.filter_by(provision_state=filters['provision_state'])
if 'provisioned_before' in filters: if 'provisioned_before' in filters:

View File

@ -118,6 +118,7 @@ class Node(Base):
driver_info = Column(db_types.JsonEncodedDict) driver_info = Column(db_types.JsonEncodedDict)
driver_internal_info = Column(db_types.JsonEncodedDict) driver_internal_info = Column(db_types.JsonEncodedDict)
clean_step = Column(db_types.JsonEncodedDict) clean_step = Column(db_types.JsonEncodedDict)
resource_class = Column(String(80), nullable=True)
raid_config = Column(db_types.JsonEncodedDict) raid_config = Column(db_types.JsonEncodedDict)
target_raid_config = Column(db_types.JsonEncodedDict) target_raid_config = Column(db_types.JsonEncodedDict)

View File

@ -47,7 +47,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# and save() validate the input of property values. # and save() validate the input of property values.
# Version 1.15: Add get_by_port_addresses # Version 1.15: Add get_by_port_addresses
# Version 1.16: Add network_interface field # Version 1.16: Add network_interface field
VERSION = '1.16' # Version 1.17: Add resource_class field
VERSION = '1.17'
dbapi = db_api.get_instance() dbapi = db_api.get_instance()
@ -99,6 +100,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# that started but failed to finish. # that started but failed to finish.
'last_error': object_fields.StringField(nullable=True), 'last_error': object_fields.StringField(nullable=True),
# Used by nova to relate the node to a flavor
'resource_class': object_fields.StringField(nullable=True),
'inspection_finished_at': object_fields.DateTimeField(nullable=True), 'inspection_finished_at': object_fields.DateTimeField(nullable=True),
'inspection_started_at': object_fields.DateTimeField(nullable=True), 'inspection_started_at': object_fields.DateTimeField(nullable=True),

View File

@ -94,11 +94,15 @@ def node_post_data(**kw):
node.pop('conductor_affinity') node.pop('conductor_affinity')
node.pop('chassis_id') node.pop('chassis_id')
node.pop('tags') node.pop('tags')
# NOTE(vdrok): network_interface was introduced in API version 1.20, return
# it only if it was explicitly requested, so that tests using older API # NOTE(jroll): pop out fields that were introduced in later API versions,
# versions don't fail # unless explicitly requested. Otherwise, these will cause tests using
# older API versions to fail.
if 'network_interface' not in kw: if 'network_interface' not in kw:
node.pop('network_interface') node.pop('network_interface')
if 'resource_class' not in kw:
node.pop('resource_class')
internal = node_controller.NodePatchType.internal_attrs() internal = node_controller.NodePatchType.internal_attrs()
return remove_internal(node, internal) return remove_internal(node, internal)

View File

@ -111,6 +111,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('raid_config', data['nodes'][0]) self.assertNotIn('raid_config', data['nodes'][0])
self.assertNotIn('target_raid_config', data['nodes'][0]) self.assertNotIn('target_raid_config', data['nodes'][0])
self.assertNotIn('network_interface', data['nodes'][0]) self.assertNotIn('network_interface', data['nodes'][0])
self.assertNotIn('resource_class', data['nodes'][0])
# never expose the chassis_id # never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0]) self.assertNotIn('chassis_id', data['nodes'][0])
@ -137,6 +138,7 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('clean_step', data) self.assertIn('clean_step', data)
self.assertIn('states', data) self.assertIn('states', data)
self.assertIn('network_interface', data) self.assertIn('network_interface', data)
self.assertIn('resource_class', data)
# never expose the chassis_id # never expose the chassis_id
self.assertNotIn('chassis_id', data) self.assertNotIn('chassis_id', data)
@ -336,6 +338,17 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertEqual(node.network_interface, self.assertEqual(node.network_interface,
new_data['nodes'][0]["network_interface"]) new_data['nodes'][0]["network_interface"])
def test_hide_fields_in_newer_versions_resource_class(self):
node = obj_utils.create_test_node(self.context,
resource_class='foo')
data = self.get_json(
'/nodes/detail', headers={api_base.Version.string: '1.20'})
self.assertNotIn('resource_class', data['nodes'][0])
new_data = self.get_json(
'/nodes/detail', headers={api_base.Version.string: '1.21'})
self.assertEqual(node.resource_class,
new_data['nodes'][0]["resource_class"])
def test_many(self): def test_many(self):
nodes = [] nodes = []
for id in range(5): for id in range(5):
@ -756,6 +769,75 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def _test_get_nodes_by_resource_class(self, detail=False):
if detail:
base_url = '/nodes/detail?resource_class=%s'
else:
base_url = '/nodes?resource_class=%s'
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake',
resource_class='foo')
node1 = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
driver='fake',
resource_class='bar')
data = self.get_json(base_url % 'foo',
headers={api_base.Version.string: "1.21"})
uuids = [n['uuid'] for n in data['nodes']]
self.assertIn(node.uuid, uuids)
self.assertNotIn(node1.uuid, uuids)
data = self.get_json(base_url % 'bar',
headers={api_base.Version.string: "1.21"})
uuids = [n['uuid'] for n in data['nodes']]
self.assertIn(node1.uuid, uuids)
self.assertNotIn(node.uuid, uuids)
def test_get_nodes_by_resource_class(self):
self._test_get_nodes_by_resource_class(detail=False)
def test_get_nodes_by_resource_class_detail(self):
self._test_get_nodes_by_resource_class(detail=True)
def _test_get_nodes_by_invalid_resource_class(self, detail=False):
if detail:
base_url = '/nodes/detail?resource_class=%s'
else:
base_url = '/nodes?resource_class=%s'
data = self.get_json(base_url % 'test',
headers={api_base.Version.string: "1.21"})
self.assertEqual(0, len(data['nodes']))
def test_get_nodes_by_invalid_resource_class(self):
self._test_get_nodes_by_invalid_resource_class(detail=False)
def test_get_nodes_by_invalid_resource_class_detail(self):
self._test_get_nodes_by_invalid_resource_class(detail=True)
def _test_get_nodes_by_resource_class_invalid_api_version(self,
detail=False):
if detail:
base_url = '/nodes/detail?resource_class=%s'
else:
base_url = '/nodes?resource_class=%s'
response = self.get_json(
base_url % 'fake',
headers={api_base.Version.string: str(api_v1.MIN_VER)},
expect_errors=True)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
self.assertTrue(response.json['error_message'])
def test_get_nodes_by_resource_class_invalid_api_version(self):
self._test_get_nodes_by_resource_class_invalid_api_version(
detail=False)
def test_get_nodes_by_resource_class_invalid_api_version_detail(self):
self._test_get_nodes_by_resource_class_invalid_api_version(detail=True)
def test_get_console_information(self): def test_get_console_information(self):
node = obj_utils.create_test_node(self.context) node = obj_utils.create_test_node(self.context)
expected_console_info = {'test': 'test-data'} expected_console_info = {'test': 'test-data'}
@ -1452,6 +1534,64 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def test_update_resource_class(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid())
self.mock_update_node.return_value = node
resource_class = 'foo'
headers = {api_base.Version.string: '1.21'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/resource_class',
'value': resource_class,
'op': 'add'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_resource_class_old_api(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid())
self.mock_update_node.return_value = node
resource_class = 'foo'
headers = {api_base.Version.string: '1.20'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/resource_class',
'value': resource_class,
'op': 'add'}],
headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def test_update_resource_class_max_length(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid())
self.mock_update_node.return_value = node
resource_class = 'f' * 80
headers = {api_base.Version.string: '1.21'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/resource_class',
'value': resource_class,
'op': 'add'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_resource_class_too_long(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid())
self.mock_update_node.return_value = node
resource_class = 'f' * 81
headers = {api_base.Version.string: '1.21'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/resource_class',
'value': resource_class,
'op': 'add'}],
headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_code)
class TestPost(test_api_base.BaseApiTest): class TestPost(test_api_base.BaseApiTest):
@ -1793,6 +1933,25 @@ class TestPost(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_int) self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_create_node_resource_class(self):
ndict = test_api_utils.post_get_test_node(
resource_class='foo')
response = self.post_json('/nodes', ndict,
headers={api_base.Version.string:
str(api_v1.MAX_VER)})
self.assertEqual(http_client.CREATED, response.status_int)
result = self.get_json('/nodes/%s' % ndict['uuid'],
headers={api_base.Version.string:
str(api_v1.MAX_VER)})
self.assertEqual('foo', result['resource_class'])
def test_create_node_resource_class_old_api_version(self):
ndict = test_api_utils.post_get_test_node(
resource_class='foo')
response = self.post_json('/nodes', ndict, expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
class TestDelete(test_api_base.BaseApiTest): class TestDelete(test_api_base.BaseApiTest):

View File

@ -131,21 +131,33 @@ class TestApiUtils(base.TestCase):
utils.check_allow_specify_fields, ['foo']) utils.check_allow_specify_fields, ['foo'])
@mock.patch.object(pecan, 'request', spec_set=['version']) @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_network_interface(self, mock_request): def test_check_allowed_fields_network_interface(self, mock_request):
mock_request.version.minor = 20 mock_request.version.minor = 20
self.assertIsNone( self.assertIsNone(
utils.check_allow_specify_network_interface_in_fields( utils.check_allowed_fields(['network_interface']))
['network_interface']))
@mock.patch.object(pecan, 'request', spec_set=['version']) @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_network_interface_in_fields_fail( def test_check_allowed_fields_network_interface_fail(self, mock_request):
self, mock_request):
mock_request.version.minor = 19 mock_request.version.minor = 19
self.assertRaises( self.assertRaises(
exception.NotAcceptable, exception.NotAcceptable,
utils.check_allow_specify_network_interface_in_fields, utils.check_allowed_fields,
['network_interface']) ['network_interface'])
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allowed_fields_resource_class(self, mock_request):
mock_request.version.minor = 21
self.assertIsNone(
utils.check_allowed_fields(['resource_class']))
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allowed_fields_resource_class_fail(self, mock_request):
mock_request.version.minor = 20
self.assertRaises(
exception.NotAcceptable,
utils.check_allowed_fields,
['resource_class'])
@mock.patch.object(pecan, 'request', spec_set=['version']) @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_driver(self, mock_request): def test_check_allow_specify_driver(self, mock_request):
mock_request.version.minor = 16 mock_request.version.minor = 16
@ -157,6 +169,17 @@ class TestApiUtils(base.TestCase):
self.assertRaises(exception.NotAcceptable, self.assertRaises(exception.NotAcceptable,
utils.check_allow_specify_driver, ['fake']) utils.check_allow_specify_driver, ['fake'])
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_resource_class(self, mock_request):
mock_request.version.minor = 21
self.assertIsNone(utils.check_allow_specify_resource_class(['foo']))
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_specify_resource_class_fail(self, mock_request):
mock_request.version.minor = 20
self.assertRaises(exception.NotAcceptable,
utils.check_allow_specify_resource_class, ['foo'])
@mock.patch.object(pecan, 'request', spec_set=['version']) @mock.patch.object(pecan, 'request', spec_set=['version'])
def test_check_allow_manage_verbs(self, mock_request): def test_check_allow_manage_verbs(self, mock_request):
mock_request.version.minor = 4 mock_request.version.minor = 4
@ -255,6 +278,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 19 mock_request.version.minor = 19
self.assertFalse(utils.allow_network_interface()) self.assertFalse(utils.allow_network_interface())
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_allow_resource_class(self, mock_request):
mock_request.version.minor = 21
self.assertTrue(utils.allow_resource_class())
mock_request.version.minor = 20
self.assertFalse(utils.allow_resource_class())
class TestNodeIdent(base.TestCase): class TestNodeIdent(base.TestCase):

View File

@ -433,6 +433,13 @@ class MigrationCheckersMixin(object):
self.assertIsInstance(portgroups.c.internal_info.type, self.assertIsInstance(portgroups.c.internal_info.type,
sqlalchemy.types.TEXT) sqlalchemy.types.TEXT)
def _check_dd34e1f1303b(self, engine, data):
nodes = db_utils.get_table(engine, 'nodes')
col_names = [column.name for column in nodes.c]
self.assertIn('resource_class', col_names)
self.assertIsInstance(nodes.c.resource_class.type,
sqlalchemy.types.String)
def test_upgrade_and_version(self): def test_upgrade_and_version(self):
with patch_with_engine(self.engine): with patch_with_engine(self.engine):
self.migration_api.upgrade('head') self.migration_api.upgrade('head')

View File

@ -123,7 +123,8 @@ class DbNodeTestCase(base.DbTestCase):
node2 = utils.create_test_node( node2 = utils.create_test_node(
driver='driver-two', driver='driver-two',
uuid=uuidutils.generate_uuid(), uuid=uuidutils.generate_uuid(),
maintenance=True) maintenance=True,
resource_class='foo')
node3 = utils.create_test_node( node3 = utils.create_test_node(
driver='driver-one', driver='driver-one',
uuid=uuidutils.generate_uuid(), uuid=uuidutils.generate_uuid(),
@ -157,6 +158,9 @@ class DbNodeTestCase(base.DbTestCase):
self.assertEqual(sorted([node1.id, node3.id]), self.assertEqual(sorted([node1.id, node3.id]),
sorted([r.id for r in res])) sorted([r.id for r in res]))
res = self.dbapi.get_node_list(filters={'resource_class': 'foo'})
self.assertEqual([node2.id], [r.id for r in res])
res = self.dbapi.get_node_list( res = self.dbapi.get_node_list(
filters={'reserved_by_any_of': ['fake-host', filters={'reserved_by_any_of': ['fake-host',
'another-fake-host']}) 'another-fake-host']})

View File

@ -226,6 +226,7 @@ def get_test_node(**kw):
'raid_config': kw.get('raid_config'), 'raid_config': kw.get('raid_config'),
'target_raid_config': kw.get('target_raid_config'), 'target_raid_config': kw.get('target_raid_config'),
'tags': kw.get('tags', []), 'tags': kw.get('tags', []),
'resource_class': kw.get('resource_class'),
'network_interface': kw.get('network_interface'), 'network_interface': kw.get('network_interface'),
} }

View File

@ -404,7 +404,7 @@ class TestObject(_LocalTest, _TestObject):
# version bump. It is md5 hash of object fields and remotable methods. # version bump. It is md5 hash of object fields and remotable methods.
# The fingerprint values should only be changed if there is a version bump. # The fingerprint values should only be changed if there is a version bump.
expected_object_fingerprints = { expected_object_fingerprints = {
'Node': '1.16-2a6646627cb937f083f428f5d54e6458', 'Node': '1.17-ed09e704576dc1b5a74abcbb727bf722',
'MyObj': '1.5-4f5efe8f0fcaf182bbe1c7fe3ba858db', 'MyObj': '1.5-4f5efe8f0fcaf182bbe1c7fe3ba858db',
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
'Port': '1.6-609504503d68982a10f495659990084b', 'Port': '1.6-609504503d68982a10f495659990084b',

View File

@ -0,0 +1,13 @@
---
features:
- Adds a `resource_class` field to the node resource,
which will be used by Nova to define which nodes may
quantitatively match a Nova flavor. Operators should
populate this accordingly before deploying the Ocata
version of Nova.
upgrade:
- Adds a `resource_class` field to the node resource,
which will be used by Nova to define which nodes may
quantitatively match a Nova flavor. Operators should
populate this accordingly before deploying the Ocata
version of Nova.